diff --git a/src/ahriman/application/handlers/create_user.py b/src/ahriman/application/handlers/create_user.py index 72c2d165..ca60f8bf 100644 --- a/src/ahriman/application/handlers/create_user.py +++ b/src/ahriman/application/handlers/create_user.py @@ -19,8 +19,10 @@ # import argparse import configparser +import getpass +import random +import string -from getpass import getpass from pathlib import Path from typing import Type @@ -42,38 +44,60 @@ class CreateUser(Handler): :param architecture: repository architecture :param configuration: configuration instance """ - user = CreateUser.create_user(args, configuration) - CreateUser.create_configuration(user, configuration.include) + salt = CreateUser.get_salt(configuration) + user = CreateUser.create_user(args, salt) + CreateUser.create_configuration(user, salt, configuration.include) @staticmethod - def create_configuration(user: User, include_path: Path) -> None: + def create_configuration(user: User, salt: str, include_path: Path) -> None: """ put new user to configuration :param user: user descriptor + :param salt: password hash salt :param include_path: path to directory with configuration includes """ + def set_option(section_name: str, name: str, value: str) -> None: + if section_name not in configuration.sections(): + configuration.add_section(section_name) + configuration.set(section_name, name, value) + target = include_path / "auth.ini" configuration = configparser.ConfigParser() - configuration.read(target) + if target.is_file(): # load current configuration in case if it exists + configuration.read(target) section = Configuration.section_name("auth", user.access.value) - configuration.add_section(section) - configuration.set(section, user.username, user.password) + set_option("auth", "salt", salt) + set_option(section, user.username, user.password) with target.open("w") as ahriman_configuration: configuration.write(ahriman_configuration) @staticmethod - def create_user(args: argparse.Namespace, configuration: Configuration) -> User: + def create_user(args: argparse.Namespace, salt: str) -> User: """ create user descriptor from arguments :param args: command line args - :param configuration: configuration instance + :param salt: password hash salt :return: built user descriptor """ user = User(args.username, args.password, args.role) if user.password is None: - user.password = getpass() - user.password = user.generate_password(user.password, configuration.get("auth", "salt")) + user.password = getpass.getpass() + user.password = user.generate_password(user.password, salt) return user + + @staticmethod + def get_salt(configuration: Configuration, salt_length: int = 20) -> str: + """ + get salt from configuration or create new string + :param configuration: configuration instance + :param salt_length: salt length + :return: current salt + """ + salt = configuration.get("auth", "salt", fallback=None) + if salt: + return salt + + return "".join(random.choices(string.ascii_letters + string.digits, k=salt_length)) diff --git a/src/ahriman/core/auth.py b/src/ahriman/core/auth.py index 2e4d0313..fcc92b8b 100644 --- a/src/ahriman/core/auth.py +++ b/src/ahriman/core/auth.py @@ -35,7 +35,7 @@ class Auth: :cvar ALLOWED_PATHS_GROUPS: URI paths prefixes which can be accessed without authorization, predefined """ - ALLOWED_PATHS = {"/", "/favicon.ico", "/login", "/logout"} + ALLOWED_PATHS = {"/favicon.ico", "/login", "/logout"} ALLOWED_PATHS_GROUPS: Set[str] = set() def __init__(self, configuration: Configuration) -> None: @@ -84,9 +84,9 @@ class Auth: :param uri: request uri :return: True in case if this URI can be requested without authorization and False otherwise """ - if uri is None: + if not uri: 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) + 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: """ diff --git a/src/ahriman/core/status/client.py b/src/ahriman/core/status/client.py index e774bf0e..0129a4b9 100644 --- a/src/ahriman/core/status/client.py +++ b/src/ahriman/core/status/client.py @@ -39,9 +39,10 @@ class Client: :param configuration: configuration instance :return: client according to current settings """ + address = configuration.get("web", "address", fallback=None) host = configuration.get("web", "host", fallback=None) port = configuration.getint("web", "port", fallback=None) - if host is not None and port is not None: + if address or (host and port): from ahriman.core.status.web_client import WebClient return WebClient(configuration) return cls() diff --git a/src/ahriman/core/status/web_client.py b/src/ahriman/core/status/web_client.py index 729bdd96..04d48386 100644 --- a/src/ahriman/core/status/web_client.py +++ b/src/ahriman/core/status/web_client.py @@ -51,7 +51,7 @@ class WebClient(Client): configuration.get("web", "password", fallback=None)) self.__session = requests.session() - self.login() + self._login() @property def _ahriman_url(self) -> str: @@ -89,7 +89,7 @@ class WebClient(Client): address = f"http://{host}:{port}" return address - def login(self) -> None: + def _login(self) -> None: """ process login to the service """ diff --git a/src/ahriman/web/middlewares/auth_handler.py b/src/ahriman/web/middlewares/auth_handler.py index df162e51..719c2df1 100644 --- a/src/ahriman/web/middlewares/auth_handler.py +++ b/src/ahriman/web/middlewares/auth_handler.py @@ -17,11 +17,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # +import aiohttp_security # type: ignore + 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 @@ -30,7 +30,7 @@ from ahriman.models.user_access import UserAccess from ahriman.web.middlewares import HandlerType, MiddlewareType -class AuthorizationPolicy(AbstractAuthorizationPolicy): # type: ignore +class AuthorizationPolicy(aiohttp_security.AbstractAuthorizationPolicy): # type: ignore """ authorization policy implementation :ivar validator: validator instance @@ -71,13 +71,14 @@ def auth_handler() -> MiddlewareType: """ @middleware async def handle(request: Request, handler: HandlerType) -> StreamResponse: + print(request) if request.path.startswith("/api"): permission = UserAccess.Status - elif request.method in ("HEAD", "GET", "OPTIONS"): + elif request.method in ("GET", "HEAD", "OPTIONS"): permission = UserAccess.Read else: permission = UserAccess.Write - await check_permission(request, permission, request.path) + await aiohttp_security.check_permission(request, permission, request.path) return await handler(request) @@ -92,10 +93,10 @@ def setup_auth(application: web.Application, configuration: Configuration) -> we :return: configured web application """ authorization_policy = AuthorizationPolicy(configuration) - identity_policy = SessionIdentityPolicy() + identity_policy = aiohttp_security.SessionIdentityPolicy() application["validator"] = authorization_policy.validator - setup_security(application, identity_policy, authorization_policy) + aiohttp_security.setup(application, identity_policy, authorization_policy) application.middlewares.append(auth_handler()) return application diff --git a/src/ahriman/web/views/login.py b/src/ahriman/web/views/login.py index 2b1b01aa..f4f3867c 100644 --- a/src/ahriman/web/views/login.py +++ b/src/ahriman/web/views/login.py @@ -17,8 +17,9 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # +import aiohttp_security # type: ignore + from aiohttp.web import HTTPFound, HTTPUnauthorized, Response -from aiohttp_security import remember # type: ignore from ahriman.web.views.base import BaseView @@ -44,8 +45,11 @@ class LoginView(BaseView): username = data.get("username") response = HTTPFound("/") - if self.validator.check_credentials(username, data.get("password")): - await remember(self.request, response, username) + try: + if self.validator.check_credentials(username, data.get("password")): + await aiohttp_security.remember(self.request, response, username) + return response + except KeyError: return response raise HTTPUnauthorized() diff --git a/src/ahriman/web/views/logout.py b/src/ahriman/web/views/logout.py index 4abe88d8..4336f603 100644 --- a/src/ahriman/web/views/logout.py +++ b/src/ahriman/web/views/logout.py @@ -17,8 +17,9 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # +import aiohttp_security # type: ignore + from aiohttp.web import HTTPFound, Response -from aiohttp_security import check_authorized, forget # type: ignore from ahriman.web.views.base import BaseView @@ -33,9 +34,9 @@ class LogoutView(BaseView): logout user from the service. No parameters supported here :return: redirect to main page """ - await check_authorized(self.request) + await aiohttp_security.check_authorized(self.request) response = HTTPFound("/") - await forget(self.request, response) + await aiohttp_security.forget(self.request, response) return response diff --git a/tests/ahriman/application/handlers/test_handler_create_user.py b/tests/ahriman/application/handlers/test_handler_create_user.py new file mode 100644 index 00000000..a8321ddf --- /dev/null +++ b/tests/ahriman/application/handlers/test_handler_create_user.py @@ -0,0 +1,132 @@ +import argparse +import pytest + +from pathlib import Path +from pytest_mock import MockerFixture +from unittest import mock + +from ahriman.application.handlers import CreateUser +from ahriman.core.configuration import Configuration +from ahriman.models.user import User +from ahriman.models.user_access import UserAccess + + +def _default_args(args: argparse.Namespace) -> argparse.Namespace: + """ + default arguments for these test cases + :param args: command line arguments fixture + :return: generated arguments for these test cases + """ + args.username = "user" + args.password = "pa55w0rd" + args.role = UserAccess.Status + return args + + +def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: + """ + must run command + """ + args = _default_args(args) + create_configuration_mock = mocker.patch("ahriman.application.handlers.CreateUser.create_configuration") + create_user = mocker.patch("ahriman.application.handlers.CreateUser.create_user") + get_salt_mock = mocker.patch("ahriman.application.handlers.CreateUser.get_salt") + + CreateUser.run(args, "x86_64", configuration) + create_configuration_mock.assert_called_once() + create_user.assert_called_once() + get_salt_mock.assert_called_once() + + +def test_create_configuration(user: User, mocker: MockerFixture) -> None: + """ + must correctly create configuration file + """ + section = Configuration.section_name("auth", user.access.value) + + mocker.patch("pathlib.Path.open") + add_section_mock = mocker.patch("configparser.RawConfigParser.add_section") + set_mock = mocker.patch("configparser.RawConfigParser.set") + write_mock = mocker.patch("configparser.RawConfigParser.write") + + CreateUser.create_configuration(user, "salt", Path("path")) + write_mock.assert_called_once() + add_section_mock.assert_has_calls([mock.call("auth"), mock.call(section)]) + set_mock.assert_has_calls([ + mock.call("auth", "salt", pytest.helpers.anyvar(str)), + mock.call(section, user.username, user.password) + ]) + + +def test_create_configuration_user_exists(configuration: Configuration, user: User, mocker: MockerFixture) -> None: + """ + must correctly update configuration file if user already exists + """ + section = Configuration.section_name("auth", user.access.value) + configuration.add_section(section) + configuration.set(section, user.username, "") + + mocker.patch("pathlib.Path.open") + mocker.patch("configparser.ConfigParser", return_value=configuration) + mocker.patch("configparser.RawConfigParser.write") + add_section_mock = mocker.patch("configparser.RawConfigParser.add_section") + + CreateUser.create_configuration(user, "salt", Path("path")) + add_section_mock.assert_not_called() + assert configuration.get(section, user.username) == user.password + + +def test_create_configuration_file_exists(user: User, mocker: MockerFixture) -> None: + """ + must correctly update configuration file if file already exists + """ + mocker.patch("pathlib.Path.open") + mocker.patch("pathlib.Path.is_file", return_value=True) + mocker.patch("configparser.RawConfigParser.write") + read_mock = mocker.patch("configparser.RawConfigParser.read") + + CreateUser.create_configuration(user, "salt", Path("path")) + read_mock.assert_called_once() + + +def test_create_user(args: argparse.Namespace, user: User) -> None: + """ + must create user + """ + args = _default_args(args) + generated = CreateUser.create_user(args, "salt") + assert generated.username == user.username + assert generated.check_credentials(user.password, "salt") + assert generated.access == user.access + + +def test_create_user_getpass(args: argparse.Namespace, mocker: MockerFixture) -> None: + """ + must create user and get password from command line + """ + args = _default_args(args) + args.password = None + + getpass_mock = mocker.patch("getpass.getpass", return_value="password") + generated = CreateUser.create_user(args, "salt") + + getpass_mock.assert_called_once() + assert generated.check_credentials("password", "salt") + + +def test_get_salt_read(configuration: Configuration) -> None: + """ + must read salt from configuration + """ + assert CreateUser.get_salt(configuration) == "salt" + + +def test_get_salt_generate(configuration: Configuration) -> None: + """ + must generate salt if it does not exist + """ + configuration.remove_option("auth", "salt") + + salt = CreateUser.get_salt(configuration, 16) + assert salt + assert len(salt) == 16 diff --git a/tests/ahriman/application/test_ahriman.py b/tests/ahriman/application/test_ahriman.py index 3e51622c..f709e308 100644 --- a/tests/ahriman/application/test_ahriman.py +++ b/tests/ahriman/application/test_ahriman.py @@ -6,6 +6,7 @@ from pytest_mock import MockerFixture from ahriman.application.handlers import Handler from ahriman.models.build_status import BuildStatusEnum from ahriman.models.sign_settings import SignSettings +from ahriman.models.user_access import UserAccess def test_parser(parser: argparse.ArgumentParser) -> None: @@ -83,6 +84,28 @@ def test_subparsers_config(parser: argparse.ArgumentParser) -> None: assert args.unsafe +def test_subparsers_create_user(parser: argparse.ArgumentParser) -> None: + """ + create-user command must imply architecture, lock, no-log, no-report and unsafe + """ + args = parser.parse_args(["create-user", "username"]) + assert args.architecture == [""] + assert args.lock is None + assert args.no_log + assert args.no_report + assert args.unsafe + + +def test_subparsers_create_user_option_role(parser: argparse.ArgumentParser) -> None: + """ + create-user command must convert role option to useraccess instance + """ + args = parser.parse_args(["create-user", "username"]) + assert isinstance(args.role, UserAccess) + args = parser.parse_args(["create-user", "username", "--role", "write"]) + assert isinstance(args.role, UserAccess) + + def test_subparsers_init(parser: argparse.ArgumentParser) -> None: """ init command must imply no_report diff --git a/tests/ahriman/conftest.py b/tests/ahriman/conftest.py index 185d1858..9dbcd4d6 100644 --- a/tests/ahriman/conftest.py +++ b/tests/ahriman/conftest.py @@ -11,7 +11,8 @@ from ahriman.core.status.watcher import Watcher from ahriman.models.package import Package from ahriman.models.package_description import PackageDescription from ahriman.models.repository_paths import RepositoryPaths - +from ahriman.models.user import User +from ahriman.models.user_access import UserAccess T = TypeVar("T") @@ -158,6 +159,15 @@ def repository_paths(configuration: Configuration) -> RepositoryPaths: root=configuration.getpath("repository", "root")) +@pytest.fixture +def user() -> User: + """ + fixture for user descriptor + :return: user descriptor instance + """ + return User("user", "pa55w0rd", UserAccess.Status) + + @pytest.fixture def watcher(configuration: Configuration, mocker: MockerFixture) -> Watcher: """ diff --git a/tests/ahriman/core/conftest.py b/tests/ahriman/core/conftest.py index 3c522c86..f800f5d0 100644 --- a/tests/ahriman/core/conftest.py +++ b/tests/ahriman/core/conftest.py @@ -2,6 +2,7 @@ import pytest from ahriman.core.alpm.pacman import Pacman from ahriman.core.alpm.repo import Repo +from ahriman.core.auth import Auth from ahriman.core.build_tools.task import Task from ahriman.core.configuration import Configuration from ahriman.core.tree import Leaf @@ -29,6 +30,15 @@ def leaf_python_schedule(package_python_schedule: Package) -> Leaf: return Leaf(package_python_schedule, set()) +@pytest.fixture +def auth(configuration: Configuration) -> Auth: + """ + auth provider fixture + :return: auth service instance + """ + return Auth(configuration) + + @pytest.fixture def pacman(configuration: Configuration) -> Pacman: """ diff --git a/tests/ahriman/core/status/test_client.py b/tests/ahriman/core/status/test_client.py index ba677d3b..fb2d2ca7 100644 --- a/tests/ahriman/core/status/test_client.py +++ b/tests/ahriman/core/status/test_client.py @@ -17,13 +17,21 @@ def test_load_dummy_client(configuration: Configuration) -> None: def test_load_full_client(configuration: Configuration) -> None: """ - must load full client if no settings set + must load full client if settings set """ configuration.set("web", "host", "localhost") configuration.set("web", "port", "8080") assert isinstance(Client.load(configuration), WebClient) +def test_load_full_client_from_address(configuration: Configuration) -> None: + """ + must load full client if settings set + """ + configuration.set("web", "address", "http://localhost:8080") + assert isinstance(Client.load(configuration), WebClient) + + def test_add(client: Client, package_ahriman: Package) -> None: """ must process package addition without errors diff --git a/tests/ahriman/core/status/test_web_client.py b/tests/ahriman/core/status/test_web_client.py index 1a816ccb..378bed4c 100644 --- a/tests/ahriman/core/status/test_web_client.py +++ b/tests/ahriman/core/status/test_web_client.py @@ -5,10 +5,12 @@ import requests from pytest_mock import MockerFixture from requests import Response +from ahriman.core.configuration import Configuration from ahriman.core.status.web_client import WebClient from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.internal_status import InternalStatus from ahriman.models.package import Package +from ahriman.models.user import User def test_ahriman_url(web_client: WebClient) -> None: @@ -27,6 +29,60 @@ def test_status_url(web_client: WebClient) -> None: assert web_client._status_url.endswith("/api/v1/status") +def test_parse_address(configuration: Configuration) -> None: + """ + must extract address correctly + """ + configuration.set("web", "host", "localhost") + configuration.set("web", "port", "8080") + assert WebClient.parse_address(configuration) == "http://localhost:8080" + + configuration.set("web", "address", "http://localhost:8081") + assert WebClient.parse_address(configuration) == "http://localhost:8081" + + +def test_login(web_client: WebClient, user: User, mocker: MockerFixture) -> None: + """ + must login user + """ + web_client.user = user + requests_mock = mocker.patch("requests.Session.post") + payload = { + "username": user.username, + "password": user.password + } + + web_client._login() + requests_mock.assert_called_with(pytest.helpers.anyvar(str, True), json=payload) + + +def test_login_failed(web_client: WebClient, user: User, mocker: MockerFixture) -> None: + """ + must suppress any exception happened during login + """ + web_client.user = user + mocker.patch("requests.Session.post", side_effect=Exception()) + web_client._login() + + +def test_login_failed_http_error(web_client: WebClient, user: User, mocker: MockerFixture) -> None: + """ + must suppress any exception happened during login + """ + web_client.user = user + mocker.patch("requests.Session.post", side_effect=requests.exceptions.HTTPError()) + web_client._login() + + +def test_login_skip(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must skip login if no user set + """ + requests_mock = mocker.patch("requests.Session.post") + web_client._login() + requests_mock.assert_not_called() + + def test_package_url(web_client: WebClient, package_ahriman: Package) -> None: """ must generate package status correctly diff --git a/tests/ahriman/core/test_auth.py b/tests/ahriman/core/test_auth.py new file mode 100644 index 00000000..7dd05fb5 --- /dev/null +++ b/tests/ahriman/core/test_auth.py @@ -0,0 +1,83 @@ +from ahriman.core.auth import Auth +from ahriman.core.configuration import Configuration +from ahriman.models.user import User +from ahriman.models.user_access import UserAccess + + +def test_get_users(auth: Auth, configuration: Configuration) -> None: + """ + must return valid user list + """ + user_write = User("user_write", "pwd_write", UserAccess.Write) + write_section = Configuration.section_name("auth", user_write.access.value) + configuration.add_section(write_section) + configuration.set(write_section, user_write.username, user_write.password) + user_read = User("user_read", "pwd_read", UserAccess.Read) + read_section = Configuration.section_name("auth", user_read.access.value) + configuration.add_section(read_section) + configuration.set(read_section, user_read.username, user_read.password) + + users = auth.get_users(configuration) + expected = {user_write.username: user_write, user_read.username: user_read} + assert users == expected + + +def test_check_credentials(auth: Auth, user: User) -> None: + """ + must return true for valid credentials + """ + current_password = user.password + user.password = user.generate_password(user.password, auth.salt) + auth.users[user.username] = user + assert auth.check_credentials(user.username, current_password) + assert not auth.check_credentials(user.username, user.password) # here password is hashed so it is invalid + + +def test_check_credentials_empty(auth: Auth) -> None: + """ + must reject on empty credentials + """ + assert not auth.check_credentials(None, "") + assert not auth.check_credentials("", None) + assert not auth.check_credentials(None, None) + + +def test_check_credentials_unknown(auth: Auth, user: User) -> None: + """ + must reject on unknown user + """ + assert not auth.check_credentials(user.username, user.password) + + +def test_is_safe_request(auth: Auth) -> None: + """ + must validate safe request + """ + # login and logout are always safe + assert auth.is_safe_request("/login") + assert auth.is_safe_request("/logout") + + auth.allowed_paths.add("/safe") + auth.allowed_paths_groups.add("/unsafe/safe") + + assert auth.is_safe_request("/safe") + assert not auth.is_safe_request("/unsafe") + assert auth.is_safe_request("/unsafe/safe") + assert auth.is_safe_request("/unsafe/safe/suffix") + + +def test_is_safe_request_empty(auth: Auth) -> None: + """ + must not allow requests without path + """ + assert not auth.is_safe_request(None) + assert not auth.is_safe_request("") + + +def test_verify_access(auth: Auth, user: User) -> None: + """ + must verify user access + """ + auth.users[user.username] = user + assert auth.verify_access(user.username, user.access) + assert not auth.verify_access(user.username, UserAccess.Write) diff --git a/tests/ahriman/models/test_user.py b/tests/ahriman/models/test_user.py new file mode 100644 index 00000000..e03154e7 --- /dev/null +++ b/tests/ahriman/models/test_user.py @@ -0,0 +1,62 @@ +from ahriman.models.user import User +from ahriman.models.user_access import UserAccess + + +def test_from_option(user: User) -> None: + """ + must generate user from options + """ + assert User.from_option(user.username, user.password) == user + # default is status access + user.access = UserAccess.Write + assert User.from_option(user.username, user.password) != user + + +def test_from_option_empty() -> None: + """ + must return nothing if settings are missed + """ + assert User.from_option(None, "") is None + assert User.from_option("", None) is None + assert User.from_option(None, None) is None + + +def test_check_credentials_generate_password(user: User) -> None: + """ + must generate and validate user password + """ + current_password = user.password + user.password = user.generate_password(current_password, "salt") + assert user.check_credentials(current_password, "salt") + assert not user.check_credentials(current_password, "salt1") + assert not user.check_credentials(user.password, "salt") + + +def test_verify_access_read(user: User) -> None: + """ + user with read access must be able to only request read + """ + user.access = UserAccess.Read + assert user.verify_access(UserAccess.Read) + assert not user.verify_access(UserAccess.Write) + assert not user.verify_access(UserAccess.Status) + + +def test_verify_access_status(user: User) -> None: + """ + user with status access must be able to only request status + """ + user.access = UserAccess.Status + assert not user.verify_access(UserAccess.Read) + assert not user.verify_access(UserAccess.Write) + assert user.verify_access(UserAccess.Status) + + +def test_verify_access_write(user: User) -> None: + """ + user with write access must be able to do anything + """ + user.access = UserAccess.Write + assert user.verify_access(UserAccess.Read) + assert user.verify_access(UserAccess.Write) + assert user.verify_access(UserAccess.Status) diff --git a/tests/ahriman/models/test_user_access.py b/tests/ahriman/models/test_user_access.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/ahriman/web/conftest.py b/tests/ahriman/web/conftest.py index 3e711022..be27cf4f 100644 --- a/tests/ahriman/web/conftest.py +++ b/tests/ahriman/web/conftest.py @@ -17,3 +17,16 @@ def application(configuration: Configuration, mocker: MockerFixture) -> web.Appl """ mocker.patch("pathlib.Path.mkdir") return setup_service("x86_64", configuration) + + +@pytest.fixture +def application_with_auth(configuration: Configuration, mocker: MockerFixture) -> web.Application: + """ + application fixture with auth enabled + :param configuration: configuration fixture + :param mocker: mocker object + :return: application test instance + """ + configuration.set("web", "auth", "yes") + mocker.patch("pathlib.Path.mkdir") + return setup_service("x86_64", configuration) diff --git a/tests/ahriman/web/middlewares/conftest.py b/tests/ahriman/web/middlewares/conftest.py index ca968d97..6a9733c2 100644 --- a/tests/ahriman/web/middlewares/conftest.py +++ b/tests/ahriman/web/middlewares/conftest.py @@ -2,8 +2,11 @@ import pytest from collections import namedtuple +from ahriman.core.configuration import Configuration +from ahriman.models.user import User +from ahriman.web.middlewares.auth_handler import AuthorizationPolicy -_request = namedtuple("_request", ["path"]) +_request = namedtuple("_request", ["path", "method"]) @pytest.fixture @@ -12,4 +15,15 @@ def aiohttp_request() -> _request: fixture for aiohttp like object :return: aiohttp like request test instance """ - return _request("path") + return _request("path", "GET") + + +@pytest.fixture +def authorization_policy(configuration: Configuration, user: User) -> AuthorizationPolicy: + """ + fixture for authorization policy + :return: authorization policy fixture + """ + policy = AuthorizationPolicy(configuration) + policy.validator.users = {user.username: user} + return policy diff --git a/tests/ahriman/web/middlewares/test_auth_handler.py b/tests/ahriman/web/middlewares/test_auth_handler.py new file mode 100644 index 00000000..dc69899a --- /dev/null +++ b/tests/ahriman/web/middlewares/test_auth_handler.py @@ -0,0 +1,105 @@ +from aiohttp import web +from pytest_mock import MockerFixture +from typing import Any +from unittest.mock import AsyncMock + +from ahriman.core.configuration import Configuration +from ahriman.models.user import User +from ahriman.models.user_access import UserAccess +from ahriman.web.middlewares.auth_handler import auth_handler, AuthorizationPolicy, setup_auth + + +async def test_authorized_userid(authorization_policy: AuthorizationPolicy, user: User) -> None: + """ + must return authorized user id + """ + assert await authorization_policy.authorized_userid(user.username) == user.username + assert await authorization_policy.authorized_userid("some random name") is None + + +async def test_permits(authorization_policy: AuthorizationPolicy, user: User, mocker: MockerFixture) -> None: + """ + must call validator check + """ + safe_request_mock = mocker.patch("ahriman.core.auth.Auth.is_safe_request", return_value=False) + verify_access_mock = mocker.patch("ahriman.core.auth.Auth.verify_access", return_value=True) + + assert await authorization_policy.permits(user.username, user.access, "/endpoint") + safe_request_mock.assert_called_with("/endpoint") + verify_access_mock.assert_called_with(user.username, user.access) + + +async def test_permits_safe(authorization_policy: AuthorizationPolicy, user: User, mocker: MockerFixture) -> None: + """ + must call validator check + """ + safe_request_mock = mocker.patch("ahriman.core.auth.Auth.is_safe_request", return_value=True) + verify_access_mock = mocker.patch("ahriman.core.auth.Auth.verify_access") + + assert await authorization_policy.permits(user.username, user.access, "/endpoint") + safe_request_mock.assert_called_with("/endpoint") + verify_access_mock.assert_not_called() + + +async def test_auth_handler_api(aiohttp_request: Any, mocker: MockerFixture) -> None: + """ + must ask for status permission for api calls + """ + aiohttp_request = aiohttp_request._replace(path="/api") + request_handler = AsyncMock() + check_permission_mock = mocker.patch("aiohttp_security.check_permission") + + handler = auth_handler() + await handler(aiohttp_request, request_handler) + check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Status, aiohttp_request.path) + + +async def test_auth_handler_api_post(aiohttp_request: Any, mocker: MockerFixture) -> None: + """ + must ask for status permission for api calls with POST + """ + aiohttp_request = aiohttp_request._replace(path="/api", method="POST") + request_handler = AsyncMock() + check_permission_mock = mocker.patch("aiohttp_security.check_permission") + + handler = auth_handler() + await handler(aiohttp_request, request_handler) + check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Status, aiohttp_request.path) + + +async def test_auth_handler_read(aiohttp_request: Any, mocker: MockerFixture) -> None: + """ + must ask for read permission for api calls with GET + """ + for method in ("GET", "HEAD", "OPTIONS"): + aiohttp_request = aiohttp_request._replace(method=method) + request_handler = AsyncMock() + check_permission_mock = mocker.patch("aiohttp_security.check_permission") + + handler = auth_handler() + await handler(aiohttp_request, request_handler) + check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Read, aiohttp_request.path) + + +async def test_auth_handler_write(aiohttp_request: Any, mocker: MockerFixture) -> None: + """ + must ask for read permission for api calls with POST + """ + for method in ("CONNECT", "DELETE", "PATCH", "POST", "PUT", "TRACE"): + aiohttp_request = aiohttp_request._replace(method=method) + request_handler = AsyncMock() + check_permission_mock = mocker.patch("aiohttp_security.check_permission") + + handler = auth_handler() + await handler(aiohttp_request, request_handler) + check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Write, aiohttp_request.path) + + +def test_setup_auth(application: web.Application, configuration: Configuration, mocker: MockerFixture) -> None: + """ + must setup authorization + """ + aiohttp_security_setup_mock = mocker.patch("aiohttp_security.setup") + application = setup_auth(application, configuration) + assert application.get("validator") is not None + aiohttp_security_setup_mock.assert_called_once() diff --git a/tests/ahriman/web/test_web.py b/tests/ahriman/web/test_web.py index d629d55b..fdfa49a9 100644 --- a/tests/ahriman/web/test_web.py +++ b/tests/ahriman/web/test_web.py @@ -41,3 +41,16 @@ def test_run(application: web.Application, mocker: MockerFixture) -> None: run_server(application) run_application_mock.assert_called_with(application, host="127.0.0.1", port=port, handle_signals=False, access_log=pytest.helpers.anyvar(int)) + + +def test_run_with_auth(application_with_auth: web.Application, mocker: MockerFixture) -> None: + """ + must run application + """ + port = 8080 + application_with_auth["configuration"].set("web", "port", str(port)) + run_application_mock = mocker.patch("aiohttp.web.run_app") + + run_server(application_with_auth) + run_application_mock.assert_called_with(application_with_auth, host="127.0.0.1", port=port, + handle_signals=False, access_log=pytest.helpers.anyvar(int)) diff --git a/tests/ahriman/web/views/test_view_login.py b/tests/ahriman/web/views/test_view_login.py new file mode 100644 index 00000000..478c308e --- /dev/null +++ b/tests/ahriman/web/views/test_view_login.py @@ -0,0 +1,48 @@ +from aiohttp.test_utils import TestClient +from pytest_mock import MockerFixture + +from ahriman.core.auth import Auth +from ahriman.core.configuration import Configuration +from ahriman.models.user import User + + +async def test_post(client: TestClient, configuration: Configuration, user: User, mocker: MockerFixture) -> None: + """ + must login user correctly + """ + client.app["validator"] = Auth(configuration) + payload = {"username": user.username, "password": user.password} + remember_patch = mocker.patch("aiohttp_security.remember") + mocker.patch("ahriman.core.auth.Auth.check_credentials", return_value=True) + + post_response = await client.post("/login", json=payload) + assert post_response.status == 200 + + post_response = await client.post("/login", data=payload) + assert post_response.status == 200 + + remember_patch.assert_called() + + +async def test_post_skip(client: TestClient, user: User, mocker: MockerFixture) -> None: + """ + must process if no auth configured + """ + payload = {"username": user.username, "password": user.password} + post_response = await client.post("/login", json=payload) + remember_patch = mocker.patch("aiohttp_security.remember") + assert post_response.status == 200 + remember_patch.assert_not_called() + + +async def test_post_unauthorized(client: TestClient, configuration: Configuration, user: User, + mocker: MockerFixture) -> None: + """ + must return unauthorized on invalid auth + """ + client.app["validator"] = Auth(configuration) + payload = {"username": user.username, "password": user.password} + mocker.patch("ahriman.core.auth.Auth.check_credentials", return_value=False) + + post_response = await client.post("/login", json=payload) + assert post_response.status == 401 diff --git a/tests/ahriman/web/views/test_view_logout.py b/tests/ahriman/web/views/test_view_logout.py new file mode 100644 index 00000000..82e7c4be --- /dev/null +++ b/tests/ahriman/web/views/test_view_logout.py @@ -0,0 +1,38 @@ +from aiohttp.test_utils import TestClient +from aiohttp.web import HTTPUnauthorized +from pytest_mock import MockerFixture + + +async def test_post(client: TestClient, mocker: MockerFixture) -> None: + """ + must logout user correctly + """ + mocker.patch("aiohttp_security.check_authorized") + forget_patch = mocker.patch("aiohttp_security.forget") + + post_response = await client.post("/logout") + assert post_response.status == 200 + forget_patch.assert_called_once() + + +async def test_post_unauthorized(client: TestClient, mocker: MockerFixture) -> None: + """ + must raise exception if unauthorized + """ + mocker.patch("aiohttp_security.check_authorized", side_effect=HTTPUnauthorized()) + forget_patch = mocker.patch("aiohttp_security.forget") + + post_response = await client.post("/logout") + assert post_response.status == 401 + forget_patch.assert_not_called() + + +async def test_post_disabled(client: TestClient, mocker: MockerFixture) -> None: + """ + must raise exception if auth is disabled + """ + forget_patch = mocker.patch("aiohttp_security.forget") + + post_response = await client.post("/logout") + assert post_response.status == 401 + forget_patch.assert_not_called() diff --git a/tests/testresources/core/ahriman.ini b/tests/testresources/core/ahriman.ini index 08c79648..e0cab29f 100644 --- a/tests/testresources/core/ahriman.ini +++ b/tests/testresources/core/ahriman.ini @@ -8,6 +8,9 @@ database = /var/lib/pacman repositories = core extra community multilib root = / +[auth] +salt = salt + [build] archbuild_flags = build_command = extra-x86_64-build @@ -54,5 +57,6 @@ region = eu-central-1 secret_key = [web] +auth = no host = 127.0.0.1 templates = ../web/templates \ No newline at end of file