diff --git a/CONFIGURING.md b/CONFIGURING.md index 050f31bb..bb32be76 100644 --- a/CONFIGURING.md +++ b/CONFIGURING.md @@ -18,6 +18,23 @@ libalpm and AUR related configuration. * `repositories` - list of pacman repositories, space separated list of strings, required. * `root` - root for alpm library, string, required. +## `auth` group + +Base authorization settings. + +* `target` - specifies authorization provider, string, optional, default `disabled`. Allowed values are `disabled`, `configuration`. +* `allowed_paths` - URI paths (exact match) which can be accessed without authorization, space separated list of strings, optional. +* `allowed_paths_groups` - URI paths prefixes which can be accessed without authorization, space separated list of strings, optional. +* `salt` - password hash salt, string, required in case if authorization enabled (automatically generated by `create-user` subcommand). + +## `auth:*` groups + +Authorization mapping. Group name must refer to user access level, i.e. it should be one of `auth:status` (internal API usage only), `auth:read` (read hidden pages), `auth:write` (everything is allowed). + +Key is always username (case-insensitive), option value depends on authorization provider: + +* `MappingAuth` (default) - reads salted password hashes from values, uses SHA512 in order to hash passwords. Password can be set by using `create-user` subcommand. + ## `build:*` groups Build related configuration. Group name must refer to architecture, e.g. it should be `build:x86_64` for x86_64 architecture. @@ -101,6 +118,9 @@ Group name must refer to architecture, e.g. it should be `s3:x86_64` for x86_64 Web server settings. If any of `host`/`port` is not set, web integration will be disabled. Group name must refer to architecture, e.g. it should be `web:x86_64` for x86_64 architecture. +* `address` - optional address in form `proto://host:port` (`port` can be omitted in case of default `proto` ports), will be used instead of `http://{host}:{port}` in case if set, string, optional. * `host` - host to bind, string, optional. +* `password` - password to authorize in web service in order to update service status, string, required in case if authorization enabled. * `port` - port to bind, int, optional. * `templates` - path to templates directory, string, required. +* `username` - username to authorize in web service in order to update service status, string, required in case if authorization enabled. diff --git a/README.md b/README.md index 953e0b28..e62edcd7 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,13 @@ Wrapper for managing custom repository inspired by [repo-scripts](https://github ## Features -* Install-configure-forget manager for own repository -* Multi-architecture support -* VCS packages support -* Sign support with gpg (repository, package, per package settings) -* Synchronization to remote services (rsync, s3) and report generation (html) -* Dependency manager -* Repository status interface +* Install-configure-forget manager for own repository. +* Multi-architecture support. +* VCS packages support. +* Sign support with gpg (repository, package, per package settings). +* Synchronization to remote services (rsync, s3) and report generation (html). +* Dependency manager. +* Repository status interface with optional authorization. ## Installation and run diff --git a/package/archlinux/PKGBUILD b/package/archlinux/PKGBUILD index 50e7358b..fac0d2f5 100644 --- a/package/archlinux/PKGBUILD +++ b/package/archlinux/PKGBUILD @@ -7,15 +7,18 @@ pkgdesc="ArcHlinux ReposItory MANager" arch=('any') url="https://github.com/arcan1s/ahriman" license=('GPL3') -depends=('devtools' 'git' 'pyalpm' 'python-aur' 'python-srcinfo') -makedepends=('python-argparse-manpage' 'python-pip') +depends=('devtools' 'git' 'pyalpm' 'python-aur' 'python-passlib' 'python-srcinfo') +makedepends=('python-pip') optdepends=('breezy: -bzr packages support' 'darcs: -darcs packages support' 'gnupg: package and repository sign' 'mercurial: -hg packages support' 'python-aiohttp: web server' 'python-aiohttp-jinja2: web server' + 'python-aiohttp-security: web server with authorization' + 'python-aiohttp-session: web server with authorization' 'python-boto3: sync to s3' + 'python-cryptography: web server with authorization' 'python-jinja: html report generation' 'rsync: sync by using rsync' 'subversion: -svn packages support') diff --git a/package/etc/ahriman.ini b/package/etc/ahriman.ini index 4e384ad7..a0872e17 100644 --- a/package/etc/ahriman.ini +++ b/package/etc/ahriman.ini @@ -8,6 +8,9 @@ database = /var/lib/pacman repositories = core extra community multilib root = / +[auth] +target = disabled + [build] archbuild_flags = build_command = extra-x86_64-build diff --git a/package/share/ahriman/build-status.jinja2 b/package/share/ahriman/build-status.jinja2 index 2355473c..ddfe798f 100644 --- a/package/share/ahriman/build-status.jinja2 +++ b/package/share/ahriman/build-status.jinja2 @@ -12,11 +12,15 @@

ahriman - {{ version }} - {{ architecture }} - {{ service.status }} + {% if authorized %} + {{ version }} + {{ architecture }} + {{ service.status }} + {% endif %}

+ {% include "login-form.jinja2" %} + {% include "login-form-hide.jinja2" %} {% include "search-line.jinja2" %}
@@ -29,15 +33,21 @@ status - {% for package in packages %} + {% if authorized %} + {% for package in packages %} + + {{ package.base }} + {{ package.packages|join("
"|safe) }} + {{ package.version }} + {{ package.timestamp }} + {{ package.status }} + + {% endfor %} + {% else %} - {{ package.base }} - {{ package.packages|join("
"|safe) }} - {{ package.version }} - {{ package.timestamp }} - {{ package.status }} + In order to see statuses you must login first - {% endfor %} + {% endif %}
@@ -46,6 +56,17 @@
  • ahriman
  • releases
  • report a bug
  • + {% if auth_enabled %} +
  • + {% if auth_username is not none %} +
    + +
    + {% else %} + + {% endif %} +
  • + {% endif %}
    diff --git a/package/share/ahriman/login-form-hide.jinja2 b/package/share/ahriman/login-form-hide.jinja2 new file mode 100644 index 00000000..af7afb38 --- /dev/null +++ b/package/share/ahriman/login-form-hide.jinja2 @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/package/share/ahriman/login-form.jinja2 b/package/share/ahriman/login-form.jinja2 new file mode 100644 index 00000000..b7f05424 --- /dev/null +++ b/package/share/ahriman/login-form.jinja2 @@ -0,0 +1,18 @@ +{#idea is from here https://www.w3schools.com/howto/howto_css_login_form.asp#} + \ No newline at end of file diff --git a/package/share/ahriman/style.jinja2 b/package/share/ahriman/style.jinja2 index 80a821c3..4fe43842 100644 --- a/package/share/ahriman/style.jinja2 +++ b/package/share/ahriman/style.jinja2 @@ -40,6 +40,7 @@ width: inherit; } + /* table description */ th, td { padding: 5px; } @@ -103,6 +104,7 @@ background-color: rgba(var(--color-success), 1.0); } + /* navigation footer description */ ul.navigation { list-style-type: none; margin: 0; @@ -115,11 +117,8 @@ float: left; } - ul.navigation li.status { - display: block; - text-align: center; - text-decoration: none; - padding: 14px 16px; + ul.navigation li.right { + float: right; } ul.navigation li a { @@ -131,6 +130,86 @@ } ul.navigation li a:hover { - background-color: rgba(var(--color-hover), 1.0); + opacity: 0.6; + } + + /* login button in footer and modal page */ + button.login { + background-color: rgba(var(--color-header), 1.0); + padding: 14px 16px; + border: none; + cursor: pointer; + width: 100%; + } + + button.login:hover { + opacity: 0.6; + } + + button.cancel { + background-color: rgba(var(--color-failed), 1.0); + padding: 14px 16px; + border: none; + cursor: pointer; + width: 100%; + } + + button.cancel:hover { + opacity: 0.6; + } + + /* modal page inputs and containers */ + input[type=text], input[type=password] { + width: 100%; + padding: 12px 20px; + margin: 8px 0; + display: inline-block; + border: 1px solid #ccc; + box-sizing: border-box; + } + + .login-container { + padding: 14px 16px; + } + + span.password { + float: right; + padding-top: 16px; + } + + .modal-login-form { + display: none; + position: fixed; + z-index: 1; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgb(0, 0, 0); + background-color: rgba(0, 0, 0, 0.4); + padding-top: 60px; + } + + .modal-login-form-content { + background-color: #fefefe; + margin: 5% auto 15% auto; + border: 1px solid #888; + width: 25%; + } + + /* modal page animation */ + .animate { + -webkit-animation: animatezoom 0.6s; + animation: animatezoom 0.6s + } + + @-webkit-keyframes animatezoom { + from {-webkit-transform: scale(0)} + to {-webkit-transform: scale(1)} + } + + @keyframes animatezoom { + from {transform: scale(0)} + to {transform: scale(1)} } diff --git a/setup.cfg b/setup.cfg index c7f8fe20..36bcbfff 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,6 +3,3 @@ test = pytest [tool:pytest] addopts = --cov=ahriman --cov-report term-missing:skip-covered --pspec - -[build_manpages] -manpages = man/ahriman.1:module=ahriman.application.ahriman:function=_parser diff --git a/setup.py b/setup.py index 594de771..3cd0c2d8 100644 --- a/setup.py +++ b/setup.py @@ -1,11 +1,10 @@ -from build_manpages import build_manpages from pathlib import Path from setuptools import setup, find_packages from typing import Any, Dict metadata_path = Path(__file__).resolve().parent / "src/ahriman/version.py" -metadata: Dict[str, Any] = dict() +metadata: Dict[str, Any] = {} with metadata_path.open() as metadata_file: exec(metadata_file.read(), metadata) # pylint: disable=exec-used @@ -31,6 +30,7 @@ setup( ], install_requires=[ "aur", + "passlib", "pyalpm", "requests", "srcinfo", @@ -97,10 +97,10 @@ setup( "Jinja2", "aiohttp", "aiohttp_jinja2", + "aiohttp_session", + "aiohttp_security", + "cryptography", + "passlib", ], }, - - cmdclass={ - "build_manpages": build_manpages.build_manpages, - } ) diff --git a/src/ahriman/application/ahriman.py b/src/ahriman/application/ahriman.py index 83268a25..39d6acc6 100644 --- a/src/ahriman/application/ahriman.py +++ b/src/ahriman/application/ahriman.py @@ -30,6 +30,8 @@ from ahriman.models.sign_settings import SignSettings # pylint thinks it is bad idea, but get the fuck off +from ahriman.models.user_access import UserAccess + SubParserAction = argparse._SubParsersAction # pylint: disable=protected-access @@ -61,6 +63,7 @@ def _parser() -> argparse.ArgumentParser: _set_check_parser(subparsers) _set_clean_parser(subparsers) _set_config_parser(subparsers) + _set_create_user_parser(subparsers) _set_init_parser(subparsers) _set_key_import_parser(subparsers) _set_rebuild_parser(subparsers) @@ -138,6 +141,30 @@ def _set_config_parser(root: SubParserAction) -> argparse.ArgumentParser: return parser +def _set_create_user_parser(root: SubParserAction) -> argparse.ArgumentParser: + """ + add parser for create user subcommand + :param root: subparsers for the commands + :return: created argument parser + """ + parser = root.add_parser( + "create-user", + help="create user for web services", + description="create user for web services with password and role. In case if password was not entered it will be asked interactively", + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument("username", help="username for web service") + parser.add_argument("-r", "--role", help="user role", type=UserAccess, choices=UserAccess, default=UserAccess.Read) + parser.add_argument("-p", "--password", help="user password") + parser.set_defaults( + handler=handlers.CreateUser, + architecture=[""], + lock=None, + no_log=True, + no_report=True, + unsafe=True) + return parser + + def _set_init_parser(root: SubParserAction) -> argparse.ArgumentParser: """ add parser for init subcommand diff --git a/src/ahriman/application/handlers/__init__.py b/src/ahriman/application/handlers/__init__.py index 1a9fdb9d..62047489 100644 --- a/src/ahriman/application/handlers/__init__.py +++ b/src/ahriman/application/handlers/__init__.py @@ -21,6 +21,7 @@ from ahriman.application.handlers.handler import Handler from ahriman.application.handlers.add import Add from ahriman.application.handlers.clean import Clean +from ahriman.application.handlers.create_user import CreateUser from ahriman.application.handlers.dump import Dump from ahriman.application.handlers.init import Init from ahriman.application.handlers.key_import import KeyImport diff --git a/src/ahriman/application/handlers/create_user.py b/src/ahriman/application/handlers/create_user.py new file mode 100644 index 00000000..b0834651 --- /dev/null +++ b/src/ahriman/application/handlers/create_user.py @@ -0,0 +1,105 @@ +# +# 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 . +# +import argparse +import getpass + +from pathlib import Path +from typing import Type + +from ahriman.application.handlers.handler import Handler +from ahriman.core.configuration import Configuration +from ahriman.models.user import User + + +class CreateUser(Handler): + """ + create user handler + """ + + @classmethod + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + """ + callback for command line + :param args: command line args + :param architecture: repository architecture + :param configuration: configuration instance + """ + salt = CreateUser.get_salt(configuration) + user = CreateUser.create_user(args, salt) + auth_configuration = CreateUser.get_auth_configuration(configuration.include) + CreateUser.create_configuration(auth_configuration, user, salt) + + @staticmethod + def create_configuration(configuration: Configuration, user: User, salt: str) -> None: + """ + put new user to configuration + :param configuration: configuration instance + :param user: user descriptor + :param salt: password hash salt + """ + section = Configuration.section_name("auth", user.access.value) + configuration.set_option("auth", "salt", salt) + configuration.set_option(section, user.username, user.password) + + if configuration.path is None: + return + with configuration.path.open("w") as ahriman_configuration: + configuration.write(ahriman_configuration) + + @staticmethod + def create_user(args: argparse.Namespace, salt: str) -> User: + """ + create user descriptor from arguments + :param args: command line args + :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.getpass() + user.password = user.hash_password(user.password, salt) + return user + + @staticmethod + def get_auth_configuration(include_path: Path) -> Configuration: + """ + create configuration instance + :param include_path: path to directory with configuration includes + :return: configuration instance. In case if there are local settings they will be loaded + """ + target = include_path / "auth.ini" + configuration = Configuration() + if target.is_file(): # load current configuration in case if it exists + configuration.load(target) + + return configuration + + @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 User.generate_password(salt_length) diff --git a/src/ahriman/application/handlers/setup.py b/src/ahriman/application/handlers/setup.py index c00237b4..34121b2e 100644 --- a/src/ahriman/application/handlers/setup.py +++ b/src/ahriman/application/handlers/setup.py @@ -18,7 +18,6 @@ # along with this program. If not, see . # import argparse -import configparser from pathlib import Path from typing import Type @@ -79,25 +78,20 @@ class Setup(Handler): :param repository: repository name :param include_path: path to directory with configuration includes """ - configuration = configparser.ConfigParser() + configuration = Configuration() section = Configuration.section_name("build", architecture) - configuration.add_section(section) - configuration.set(section, "build_command", str(Setup.build_command(args.build_command, architecture))) - - configuration.add_section("repository") - configuration.set("repository", "name", repository) + configuration.set_option(section, "build_command", str(Setup.build_command(args.build_command, architecture))) + configuration.set_option("repository", "name", repository) if args.sign_key is not None: section = Configuration.section_name("sign", architecture) - configuration.add_section(section) - configuration.set(section, "target", " ".join([target.name.lower() for target in args.sign_target])) - configuration.set(section, "key", args.sign_key) + configuration.set_option(section, "target", " ".join([target.name.lower() for target in args.sign_target])) + configuration.set_option(section, "key", args.sign_key) if args.web_port is not None: section = Configuration.section_name("web", architecture) - configuration.add_section(section) - configuration.set(section, "port", str(args.web_port)) + configuration.set_option(section, "port", str(args.web_port)) target = include_path / "setup-overrides.ini" with target.open("w") as ahriman_configuration: @@ -115,7 +109,7 @@ class Setup(Handler): :param repository: repository name :param paths: repository paths instance """ - configuration = configparser.ConfigParser() + configuration = Configuration() # preserve case # stupid mypy thinks that it is impossible configuration.optionxform = lambda key: key # type: ignore @@ -125,17 +119,15 @@ class Setup(Handler): configuration.read(source) # set our architecture now - configuration.set("options", "Architecture", architecture) + configuration.set_option("options", "Architecture", architecture) # add multilib if not no_multilib: - configuration.add_section("multilib") - configuration.set("multilib", "Include", str(Setup.MIRRORLIST_PATH)) + configuration.set_option("multilib", "Include", str(Setup.MIRRORLIST_PATH)) # add repository itself - configuration.add_section(repository) - configuration.set(repository, "SigLevel", "Optional TrustAll") # we don't care - configuration.set(repository, "Server", f"file://{paths.repository}") + configuration.set_option(repository, "SigLevel", "Optional TrustAll") # we don't care + configuration.set_option(repository, "Server", f"file://{paths.repository}") target = source.parent / f"pacman-{prefix}-{architecture}.conf" with target.open("w") as devtools_configuration: diff --git a/src/ahriman/core/auth/__init__.py b/src/ahriman/core/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/ahriman/core/auth/auth.py b/src/ahriman/core/auth/auth.py new file mode 100644 index 00000000..9ec1a1d8 --- /dev/null +++ b/src/ahriman/core/auth/auth.py @@ -0,0 +1,105 @@ +# +# 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 typing import Optional, Set, Type + +from ahriman.core.configuration import Configuration +from ahriman.models.auth_settings import AuthSettings +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 enabled: indicates if authorization is enabled + :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", "/index.html", "/login", "/logout"} + ALLOWED_PATHS_GROUPS: Set[str] = set() + + def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.Disabled) -> None: + """ + default constructor + :param configuration: configuration instance + :param provider: authorization type definition + """ + 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) + self.enabled = provider.is_enabled + + @classmethod + def load(cls: Type[Auth], configuration: Configuration) -> Auth: + """ + load authorization module from settings + :param configuration: configuration instance + :return: authorization module according to current settings + """ + provider = AuthSettings.from_option(configuration.get("auth", "target", fallback="disabled")) + if provider == AuthSettings.Configuration: + from ahriman.core.auth.mapping_auth import MappingAuth + return MappingAuth(configuration) + return cls(configuration) + + def check_credentials(self, username: Optional[str], password: Optional[str]) -> bool: # pylint: disable=no-self-use + """ + validate user password + :param username: username + :param password: entered password + :return: True in case if password matches, False otherwise + """ + del username, password + return True + + 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 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) + + def known_username(self, username: str) -> bool: # pylint: disable=no-self-use + """ + check if user is known + :param username: username + :return: True in case if user is known and can be authorized and False otherwise + """ + del username + return True + + def verify_access(self, username: str, required: UserAccess, context: Optional[str]) -> bool: # pylint: disable=no-self-use + """ + validate if user has access to requested resource + :param username: username + :param required: required access level + :param context: URI request path + :return: True in case if user is allowed to do this request and False otherwise + """ + del username, required, context + return True diff --git a/src/ahriman/core/auth/helpers.py b/src/ahriman/core/auth/helpers.py new file mode 100644 index 00000000..f94ddc98 --- /dev/null +++ b/src/ahriman/core/auth/helpers.py @@ -0,0 +1,70 @@ +# +# 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 Any + +try: + import aiohttp_security # type: ignore + _has_aiohttp_security = True +except ImportError: + _has_aiohttp_security = False + + +async def authorized_userid(*args: Any) -> Any: + """ + handle aiohttp security methods + :param args: argument list as provided by authorized_userid function + :return: None in case if no aiohttp_security module found and function call otherwise + """ + if _has_aiohttp_security: + return await aiohttp_security.authorized_userid(*args) # pylint: disable=no-value-for-parameter + return None + + +async def check_authorized(*args: Any) -> Any: + """ + handle aiohttp security methods + :param args: argument list as provided by check_authorized function + :return: None in case if no aiohttp_security module found and function call otherwise + """ + if _has_aiohttp_security: + return await aiohttp_security.check_authorized(*args) # pylint: disable=no-value-for-parameter + return None + + +async def forget(*args: Any) -> Any: + """ + handle aiohttp security methods + :param args: argument list as provided by forget function + :return: None in case if no aiohttp_security module found and function call otherwise + """ + if _has_aiohttp_security: + return await aiohttp_security.forget(*args) # pylint: disable=no-value-for-parameter + return None + + +async def remember(*args: Any) -> Any: + """ + handle disabled auth + :param args: argument list as provided by remember function + :return: None in case if no aiohttp_security module found and function call otherwise + """ + if _has_aiohttp_security: + return await aiohttp_security.remember(*args) # pylint: disable=no-value-for-parameter + return None diff --git a/src/ahriman/core/auth/mapping_auth.py b/src/ahriman/core/auth/mapping_auth.py new file mode 100644 index 00000000..3a25b9d3 --- /dev/null +++ b/src/ahriman/core/auth/mapping_auth.py @@ -0,0 +1,105 @@ +# +# 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 + +from ahriman.core.auth.auth import Auth +from ahriman.core.configuration import Configuration +from ahriman.core.exceptions import DuplicateUser +from ahriman.models.auth_settings import AuthSettings +from ahriman.models.user import User +from ahriman.models.user_access import UserAccess + + +class MappingAuth(Auth): + """ + user authorization based on mapping from configuration file + :ivar salt: random generated string to salt passwords + :ivar _users: map of username to its descriptor + """ + + def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.Configuration) -> None: + """ + default constructor + :param configuration: configuration instance + :param provider: authorization type definition + """ + Auth.__init__(self, configuration, provider) + self.salt = configuration.get("auth", "salt") + self._users = self.get_users(configuration) + + @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(): + normalized_user = user.lower() + if normalized_user in users: + raise DuplicateUser(normalized_user) + users[normalized_user] = User(normalized_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 + user = self.get_user(username) + return user is not None and user.check_credentials(password, self.salt) + + def get_user(self, username: str) -> Optional[User]: + """ + retrieve user from in-memory mapping + :param username: username + :return: user descriptor if username is known and None otherwise + """ + normalized_user = username.lower() + return self._users.get(normalized_user) + + def known_username(self, username: str) -> bool: + """ + check if user is known + :param username: username + :return: True in case if user is known and can be authorized and False otherwise + """ + return self.get_user(username) is not None + + def verify_access(self, username: str, required: UserAccess, context: Optional[str]) -> bool: + """ + validate if user has access to requested resource + :param username: username + :param required: required access level + :param context: URI request path + :return: True in case if user is allowed to do this request and False otherwise + """ + del context + user = self.get_user(username) + return user is not None and user.verify_access(required) diff --git a/src/ahriman/core/configuration.py b/src/ahriman/core/configuration.py index b55d9e8d..c6eaf7f6 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]]: """ @@ -170,18 +170,27 @@ class Configuration(configparser.RawConfigParser): :param architecture: repository architecture """ for section in self.ARCHITECTURE_SPECIFIC_SECTIONS: - if not self.has_section(section): - self.add_section(section) # add section if not exists # get overrides specific = self.section_name(section, architecture) if self.has_section(specific): # if there is no such section it means that there is no overrides for this arch # but we anyway will have to delete sections for others archs for key, value in self[specific].items(): - self.set(section, key, value) + self.set_option(section, key, value) # remove any arch specific section for foreign in self.sections(): # we would like to use lambda filter here, but pylint is too dumb if not foreign.startswith(f"{section}:"): continue self.remove_section(foreign) + + def set_option(self, section: str, option: str, value: Optional[str]) -> None: + """ + set option. Unlike default `configparser.RawConfigParser.set` it also creates section if it does not exist + :param section: section name + :param option: option name + :param value: option value as string in parsable format + """ + if not self.has_section(section): + self.add_section(section) + self.set(section, option, value) diff --git a/src/ahriman/core/exceptions.py b/src/ahriman/core/exceptions.py index 0be1fadd..1e452517 100644 --- a/src/ahriman/core/exceptions.py +++ b/src/ahriman/core/exceptions.py @@ -45,6 +45,19 @@ class DuplicateRun(Exception): Exception.__init__(self, "Another application instance is run") +class DuplicateUser(Exception): + """ + exception which will be thrown in case if there are two users with different settings + """ + + def __init__(self, username: str) -> None: + """ + default constructor + :param username: username with duplicates + """ + Exception.__init__(self, f"Found duplicate user with username {username}") + + class InitializeException(Exception): """ base service initialization exception diff --git a/src/ahriman/core/status/client.py b/src/ahriman/core/status/client.py index ea1b8087..0129a4b9 100644 --- a/src/ahriman/core/status/client.py +++ b/src/ahriman/core/status/client.py @@ -39,11 +39,12 @@ 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(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..04d48386 100644 --- a/src/ahriman/core/status/web_client.py +++ b/src/ahriman/core/status/web_client.py @@ -22,37 +22,92 @@ 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() + self._login() + + @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 +115,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 +129,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 +143,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 +163,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 +180,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 +197,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 +213,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 +228,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/auth_settings.py b/src/ahriman/models/auth_settings.py new file mode 100644 index 00000000..46294e30 --- /dev/null +++ b/src/ahriman/models/auth_settings.py @@ -0,0 +1,58 @@ +# +# 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 enum import Enum, auto +from typing import Type + +from ahriman.core.exceptions import InvalidOption + + +class AuthSettings(Enum): + """ + web authorization type + :cvar Disabled: authorization is disabled + :cvar Configuration: configuration based authorization + """ + + Disabled = auto() + Configuration = auto() + + @classmethod + def from_option(cls: Type[AuthSettings], value: str) -> AuthSettings: + """ + construct value from configuration + :param value: configuration value + :return: parsed value + """ + if value.lower() in ("disabled", "no"): + return cls.Disabled + if value.lower() in ("configuration", "mapping"): + return cls.Configuration + raise InvalidOption(value) + + @property + def is_enabled(self) -> bool: + """ + :return: False in case if authorization is disabled and True otherwise + """ + if self == AuthSettings.Disabled: + return False + return True diff --git a/src/ahriman/models/user.py b/src/ahriman/models/user.py new file mode 100644 index 00000000..b76d9dc6 --- /dev/null +++ b/src/ahriman/models/user.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 __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Type +from passlib.pwd import genword as generate_password # type: ignore +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 + + _HASHER = sha512_crypt + + @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) + + @staticmethod + def generate_password(length: int) -> str: + """ + generate password with specified length + :param length: password length + :return: random string which contains letters and numbers + """ + password: str = generate_password(length=length) + return password + + 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 = self._HASHER.verify(password + salt, self.password) + return verified + + def hash_password(self, password: str, salt: str) -> str: + """ + generate hashed password from plain text + :param password: entered password + :param salt: salt for hashed password + :return: hashed string to store in configuration + """ + password_hash: str = self._HASHER.hash(password + salt) + return password_hash + + 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..e2b8f860 --- /dev/null +++ b/src/ahriman/web/middlewares/auth_handler.py @@ -0,0 +1,109 @@ +# +# 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 . +# +import aiohttp_security # type: ignore +import base64 + +from aiohttp import web +from aiohttp.web import middleware, Request +from aiohttp.web_response import StreamResponse +from aiohttp_session import setup as setup_session # type: ignore +from aiohttp_session.cookie_storage import EncryptedCookieStorage # type: ignore +from cryptography import fernet +from typing import Optional + +from ahriman.core.auth.auth import Auth +from ahriman.models.user_access import UserAccess +from ahriman.web.middlewares import HandlerType, MiddlewareType + + +class AuthorizationPolicy(aiohttp_security.AbstractAuthorizationPolicy): # type: ignore + """ + authorization policy implementation + :ivar validator: validator instance + """ + + def __init__(self, validator: Auth) -> None: + """ + default constructor + :param validator: authorization module instance + """ + self.validator = validator + + 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 self.validator.known_username(identity) 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 + """ + return self.validator.verify_access(identity, permission, context) + + +def auth_handler(validator: Auth) -> MiddlewareType: + """ + authorization and authentication middleware + :param validator: authorization module instance + :return: built middleware + """ + @middleware + async def handle(request: Request, handler: HandlerType) -> StreamResponse: + if request.path.startswith("/api"): + permission = UserAccess.Status + elif request.method in ("GET", "HEAD", "OPTIONS"): + permission = UserAccess.Read + else: + permission = UserAccess.Write + + if not validator.is_safe_request(request.path): + await aiohttp_security.check_permission(request, permission, request.path) + + return await handler(request) + + return handle + + +def setup_auth(application: web.Application, validator: Auth) -> web.Application: + """ + setup authorization policies for the application + :param application: web application instance + :param validator: authorization module instance + :return: configured web application + """ + 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) + + authorization_policy = AuthorizationPolicy(validator) + identity_policy = aiohttp_security.SessionIdentityPolicy() + + aiohttp_security.setup(application, identity_policy, authorization_policy) + application.middlewares.append(auth_handler(validator)) + + 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..9b10ba68 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.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/index.py b/src/ahriman/web/views/index.py index 57b96811..6406add3 100644 --- a/src/ahriman/web/views/index.py +++ b/src/ahriman/web/views/index.py @@ -22,6 +22,7 @@ import aiohttp_jinja2 from typing import Any, Dict from ahriman import version +from ahriman.core.auth.helpers import authorized_userid from ahriman.core.util import pretty_datetime from ahriman.web.views.base import BaseView @@ -33,6 +34,9 @@ class IndexView(BaseView): It uses jinja2 templates for report generation, the following variables are allowed: architecture - repository architecture, string, required + authorized - alias for `not auth_enabled or auth_username is not None` + auth_enabled - whether authorization is enabled by configuration or not, boolean, required + auth_username - authorized user id if any, string. None means not authorized packages - sorted list of packages properties, required * base, string * depends, sorted list of strings @@ -77,8 +81,15 @@ class IndexView(BaseView): "timestamp": pretty_datetime(self.service.status.timestamp) } + # auth block + auth_username = await authorized_userid(self.request) + authorized = not self.validator.enabled or auth_username is not None + return { "architecture": self.service.architecture, + "authorized": authorized, + "auth_enabled": self.validator.enabled, + "auth_username": auth_username, "packages": packages, "repository": self.service.repository.name, "service": service, diff --git a/src/ahriman/web/views/login.py b/src/ahriman/web/views/login.py new file mode 100644 index 00000000..8155e9d5 --- /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 ahriman.core.auth.helpers import remember +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..5e0ecde0 --- /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 ahriman.core.auth.helpers import check_authorized, forget +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..1cf98d3e 100644 --- a/src/ahriman/web/web.py +++ b/src/ahriman/web/web.py @@ -23,6 +23,7 @@ import logging from aiohttp import web +from ahriman.core.auth.auth import Auth from ahriman.core.configuration import Configuration from ahriman.core.exceptions import InitializeException from ahriman.core.status.watcher import Watcher @@ -92,4 +93,10 @@ def setup_service(architecture: str, configuration: Configuration) -> web.Applic application.logger.info("setup watcher") application["watcher"] = Watcher(architecture, configuration) + application.logger.info("setup authorization") + validator = application["validator"] = Auth.load(configuration) + if validator.enabled: + from ahriman.web.middlewares.auth_handler import setup_auth + setup_auth(application, validator) + return application 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..059024c9 --- /dev/null +++ b/tests/ahriman/application/handlers/test_handler_create_user.py @@ -0,0 +1,144 @@ +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) + get_auth_configuration_mock = mocker.patch("ahriman.application.handlers.CreateUser.get_auth_configuration") + 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) + get_auth_configuration_mock.assert_called_once() + create_configuration_mock.assert_called_once() + create_user.assert_called_once() + get_salt_mock.assert_called_once() + + +def test_create_configuration(configuration: Configuration, user: User, mocker: MockerFixture) -> None: + """ + must correctly create configuration file + """ + section = Configuration.section_name("auth", user.access.value) + mocker.patch("pathlib.Path.open") + set_mock = mocker.patch("ahriman.core.configuration.Configuration.set_option") + write_mock = mocker.patch("ahriman.core.configuration.Configuration.write") + + CreateUser.create_configuration(configuration, user, "salt") + write_mock.assert_called_once() + set_mock.assert_has_calls([ + mock.call("auth", "salt", pytest.helpers.anyvar(str)), + mock.call(section, user.username, user.password) + ]) + + +def test_create_configuration_not_loaded(configuration: Configuration, user: User, mocker: MockerFixture) -> None: + """ + must do nothing in case if configuration is not loaded + """ + configuration.path = None + mocker.patch("pathlib.Path.open") + write_mock = mocker.patch("ahriman.core.configuration.Configuration.write") + + CreateUser.create_configuration(configuration, user, "salt") + write_mock.assert_not_called() + + +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.set_option(section, user.username, "") + mocker.patch("pathlib.Path.open") + mocker.patch("ahriman.core.configuration.Configuration.write") + + CreateUser.create_configuration(configuration, user, "salt") + assert configuration.get(section, user.username) == user.password + + +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 + + +def test_get_auth_configuration() -> None: + """ + must load empty configuration + """ + assert CreateUser.get_auth_configuration(Path("path")) + + +def test_get_auth_configuration_exists(mocker: MockerFixture) -> None: + """ + must load configuration from filesystem + """ + mocker.patch("pathlib.Path.open") + mocker.patch("pathlib.Path.is_file", return_value=True) + read_mock = mocker.patch("ahriman.core.configuration.Configuration.read") + + CreateUser.get_auth_configuration(Path("path")) + read_mock.assert_called_once() diff --git a/tests/ahriman/application/handlers/test_handler_setup.py b/tests/ahriman/application/handlers/test_handler_setup.py index dc0c6e00..8e5ca894 100644 --- a/tests/ahriman/application/handlers/test_handler_setup.py +++ b/tests/ahriman/application/handlers/test_handler_setup.py @@ -62,19 +62,12 @@ def test_create_ahriman_configuration(args: argparse.Namespace, configuration: C """ args = _default_args(args) 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") + set_option_mock = mocker.patch("ahriman.core.configuration.Configuration.set_option") + write_mock = mocker.patch("ahriman.core.configuration.Configuration.write") command = Setup.build_command(args.build_command, "x86_64") Setup.create_ahriman_configuration(args, "x86_64", args.repository, configuration.include) - add_section_mock.assert_has_calls([ - mock.call(Configuration.section_name("build", "x86_64")), - mock.call("repository"), - mock.call(Configuration.section_name("sign", "x86_64")), - mock.call(Configuration.section_name("web", "x86_64")), - ]) - set_mock.assert_has_calls([ + set_option_mock.assert_has_calls([ mock.call(Configuration.section_name("build", "x86_64"), "build_command", str(command)), mock.call("repository", "name", args.repository), mock.call(Configuration.section_name("sign", "x86_64"), "target", @@ -92,9 +85,9 @@ def test_create_devtools_configuration(args: argparse.Namespace, repository_path """ args = _default_args(args) mocker.patch("pathlib.Path.open") - mocker.patch("configparser.RawConfigParser.set") - add_section_mock = mocker.patch("configparser.RawConfigParser.add_section") - write_mock = mocker.patch("configparser.RawConfigParser.write") + mocker.patch("ahriman.core.configuration.Configuration.set") + add_section_mock = mocker.patch("ahriman.core.configuration.Configuration.add_section") + write_mock = mocker.patch("ahriman.core.configuration.Configuration.write") Setup.create_devtools_configuration(args.build_command, "x86_64", args.from_configuration, args.no_multilib, args.repository, repository_paths) @@ -112,13 +105,11 @@ def test_create_devtools_configuration_no_multilib(args: argparse.Namespace, rep """ args = _default_args(args) mocker.patch("pathlib.Path.open") - mocker.patch("configparser.RawConfigParser.set") - add_section_mock = mocker.patch("configparser.RawConfigParser.add_section") - write_mock = mocker.patch("configparser.RawConfigParser.write") + mocker.patch("ahriman.core.configuration.Configuration.set") + write_mock = mocker.patch("ahriman.core.configuration.Configuration.write") Setup.create_devtools_configuration(args.build_command, "x86_64", args.from_configuration, True, args.repository, repository_paths) - add_section_mock.assert_called_once() write_mock.assert_called_once() 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..99a600fe 100644 --- a/tests/ahriman/conftest.py +++ b/tests/ahriman/conftest.py @@ -1,17 +1,17 @@ -from unittest.mock import MagicMock - import pytest from pathlib import Path from pytest_mock import MockerFixture from typing import Any, Type, TypeVar +from ahriman.core.auth.auth import Auth from ahriman.core.configuration import Configuration 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") @@ -43,6 +43,15 @@ def anyvar(cls: Type[T], strict: bool = False) -> T: # generic fixtures +@pytest.fixture +def auth(configuration: Configuration) -> Auth: + """ + auth provider fixture + :return: auth service instance + """ + return Auth(configuration) + + @pytest.fixture def configuration(resource_path_root: Path) -> Configuration: """ @@ -158,6 +167,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/auth/conftest.py b/tests/ahriman/core/auth/conftest.py new file mode 100644 index 00000000..4430b262 --- /dev/null +++ b/tests/ahriman/core/auth/conftest.py @@ -0,0 +1,13 @@ +import pytest + +from ahriman.core.auth.mapping_auth import MappingAuth +from ahriman.core.configuration import Configuration + + +@pytest.fixture +def mapping_auth(configuration: Configuration) -> MappingAuth: + """ + auth provider fixture + :return: auth service instance + """ + return MappingAuth(configuration) diff --git a/tests/ahriman/core/auth/test_auth.py b/tests/ahriman/core/auth/test_auth.py new file mode 100644 index 00000000..fe42b1d4 --- /dev/null +++ b/tests/ahriman/core/auth/test_auth.py @@ -0,0 +1,81 @@ +from ahriman.core.auth.auth import Auth +from ahriman.core.auth.mapping_auth import MappingAuth +from ahriman.core.configuration import Configuration +from ahriman.models.user import User +from ahriman.models.user_access import UserAccess + + +def test_load_dummy(configuration: Configuration) -> None: + """ + must load dummy validator if authorization is not enabled + """ + configuration.set_option("auth", "target", "disabled") + auth = Auth.load(configuration) + assert isinstance(auth, Auth) + + +def test_load_dummy_empty(configuration: Configuration) -> None: + """ + must load dummy validator if no option set + """ + auth = Auth.load(configuration) + assert isinstance(auth, Auth) + + +def test_load_mapping(configuration: Configuration) -> None: + """ + must load mapping validator if option set + """ + configuration.set_option("auth", "target", "configuration") + auth = Auth.load(configuration) + assert isinstance(auth, MappingAuth) + + +def test_check_credentials(auth: Auth, user: User) -> None: + """ + must pass any credentials + """ + assert auth.check_credentials(user.username, user.password) + assert auth.check_credentials(None, "") + assert auth.check_credentials("", None) + assert auth.check_credentials(None, None) + + +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_known_username(auth: Auth, user: User) -> None: + """ + must allow any username + """ + assert auth.known_username(user.username) + + +def test_verify_access(auth: Auth, user: User) -> None: + """ + must allow any access + """ + assert auth.verify_access(user.username, user.access, None) + assert auth.verify_access(user.username, UserAccess.Write, None) diff --git a/tests/ahriman/core/auth/test_helpers.py b/tests/ahriman/core/auth/test_helpers.py new file mode 100644 index 00000000..042f1e57 --- /dev/null +++ b/tests/ahriman/core/auth/test_helpers.py @@ -0,0 +1,102 @@ +import importlib +import sys + +import ahriman.core.auth.helpers as helpers + +from pytest_mock import MockerFixture + + +def test_import_aiohttp_security() -> None: + """ + must import aiohttp_security correctly + """ + assert helpers._has_aiohttp_security + + +def test_import_aiohttp_security_missing(mocker: MockerFixture) -> None: + """ + must set missing flag if no aiohttp_security module found + """ + mocker.patch.dict(sys.modules, {"aiohttp_security": None}) + importlib.reload(helpers) + assert not helpers._has_aiohttp_security + + +async def test_authorized_userid_dummy(mocker: MockerFixture) -> None: + """ + must not call authorized_userid from library if not enabled + """ + mocker.patch.object(helpers, "_has_aiohttp_security", False) + authorized_userid_mock = mocker.patch("aiohttp_security.authorized_userid") + await helpers.authorized_userid() + authorized_userid_mock.assert_not_called() + + +async def test_authorized_userid_library(mocker: MockerFixture) -> None: + """ + must call authorized_userid from library if enabled + """ + mocker.patch.object(helpers, "_has_aiohttp_security", True) + authorized_userid_mock = mocker.patch("aiohttp_security.authorized_userid") + await helpers.authorized_userid() + authorized_userid_mock.assert_called_once() + + +async def test_check_authorized_dummy(mocker: MockerFixture) -> None: + """ + must not call check_authorized from library if not enabled + """ + mocker.patch.object(helpers, "_has_aiohttp_security", False) + check_authorized_mock = mocker.patch("aiohttp_security.check_authorized") + await helpers.check_authorized() + check_authorized_mock.assert_not_called() + + +async def test_check_authorized_library(mocker: MockerFixture) -> None: + """ + must call check_authorized from library if enabled + """ + mocker.patch.object(helpers, "_has_aiohttp_security", True) + check_authorized_mock = mocker.patch("aiohttp_security.check_authorized") + await helpers.check_authorized() + check_authorized_mock.assert_called_once() + + +async def test_forget_dummy(mocker: MockerFixture) -> None: + """ + must not call forget from library if not enabled + """ + mocker.patch.object(helpers, "_has_aiohttp_security", False) + forget_mock = mocker.patch("aiohttp_security.forget") + await helpers.forget() + forget_mock.assert_not_called() + + +async def test_forget_library(mocker: MockerFixture) -> None: + """ + must call forget from library if enabled + """ + mocker.patch.object(helpers, "_has_aiohttp_security", True) + forget_mock = mocker.patch("aiohttp_security.forget") + await helpers.forget() + forget_mock.assert_called_once() + + +async def test_remember_dummy(mocker: MockerFixture) -> None: + """ + must not call remember from library if not enabled + """ + mocker.patch.object(helpers, "_has_aiohttp_security", False) + remember_mock = mocker.patch("aiohttp_security.remember") + await helpers.remember() + remember_mock.assert_not_called() + + +async def test_remember_library(mocker: MockerFixture) -> None: + """ + must call remember from library if enabled + """ + mocker.patch.object(helpers, "_has_aiohttp_security", True) + remember_mock = mocker.patch("aiohttp_security.remember") + await helpers.remember() + remember_mock.assert_called_once() diff --git a/tests/ahriman/core/auth/test_mapping_auth.py b/tests/ahriman/core/auth/test_mapping_auth.py new file mode 100644 index 00000000..e6fe1972 --- /dev/null +++ b/tests/ahriman/core/auth/test_mapping_auth.py @@ -0,0 +1,121 @@ +import pytest + +from ahriman.core.auth.mapping_auth import MappingAuth +from ahriman.core.configuration import Configuration +from ahriman.core.exceptions import DuplicateUser +from ahriman.models.user import User +from ahriman.models.user_access import UserAccess + + +def test_get_users(mapping_auth: MappingAuth, 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.set_option(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.set_option(read_section, user_read.username, user_read.password) + user_read = User("user_read", "pwd_read", UserAccess.Read) + read_section = Configuration.section_name("auth", user_read.access.value) + configuration.set_option(read_section, user_read.username, user_read.password) + + users = mapping_auth.get_users(configuration) + expected = {user_write.username: user_write, user_read.username: user_read} + assert users == expected + + +def test_get_users_normalized(mapping_auth: MappingAuth, configuration: Configuration) -> None: + """ + must return user list with normalized usernames in keys + """ + user = User("UsEr", "pwd_read", UserAccess.Read) + read_section = Configuration.section_name("auth", user.access.value) + configuration.set_option(read_section, user.username, user.password) + + users = mapping_auth.get_users(configuration) + expected = user.username.lower() + assert expected in users + assert users[expected].username == expected + + +def test_get_users_duplicate(mapping_auth: MappingAuth, configuration: Configuration, user: User) -> None: + """ + must raise exception on duplicate username + """ + write_section = Configuration.section_name("auth", UserAccess.Write.value) + configuration.set_option(write_section, user.username, user.password) + read_section = Configuration.section_name("auth", UserAccess.Read.value) + configuration.set_option(read_section, user.username, user.password) + + with pytest.raises(DuplicateUser): + mapping_auth.get_users(configuration) + + +def test_check_credentials(mapping_auth: MappingAuth, user: User) -> None: + """ + must return true for valid credentials + """ + current_password = user.password + user.password = user.hash_password(user.password, mapping_auth.salt) + mapping_auth._users[user.username] = user + assert mapping_auth.check_credentials(user.username, current_password) + assert not mapping_auth.check_credentials(user.username, user.password) # here password is hashed so it is invalid + + +def test_check_credentials_empty(mapping_auth: MappingAuth) -> None: + """ + must reject on empty credentials + """ + assert not mapping_auth.check_credentials(None, "") + assert not mapping_auth.check_credentials("", None) + assert not mapping_auth.check_credentials(None, None) + + +def test_check_credentials_unknown(mapping_auth: MappingAuth, user: User) -> None: + """ + must reject on unknown user + """ + assert not mapping_auth.check_credentials(user.username, user.password) + + +def test_get_user(mapping_auth: MappingAuth, user: User) -> None: + """ + must return user from storage by username + """ + mapping_auth._users[user.username] = user + assert mapping_auth.get_user(user.username) == user + + +def test_get_user_normalized(mapping_auth: MappingAuth, user: User) -> None: + """ + must return user from storage by username case-insensitive + """ + mapping_auth._users[user.username] = user + assert mapping_auth.get_user(user.username.upper()) == user + + +def test_get_user_unknown(mapping_auth: MappingAuth, user: User) -> None: + """ + must return None in case if no user found + """ + assert mapping_auth.get_user(user.username) is None + + +def test_known_username(mapping_auth: MappingAuth, user: User) -> None: + """ + must allow only known users + """ + mapping_auth._users[user.username] = user + assert mapping_auth.known_username(user.username) + assert not mapping_auth.known_username(user.password) + + +def test_verify_access(mapping_auth: MappingAuth, user: User) -> None: + """ + must verify user access + """ + mapping_auth._users[user.username] = user + assert mapping_auth.verify_access(user.username, user.access, None) + assert not mapping_auth.verify_access(user.username, UserAccess.Write, None) diff --git a/tests/ahriman/core/conftest.py b/tests/ahriman/core/conftest.py index 3c522c86..69447e16 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.auth import Auth from ahriman.core.build_tools.task import Task from ahriman.core.configuration import Configuration from ahriman.core.tree import Leaf diff --git a/tests/ahriman/core/report/test_email.py b/tests/ahriman/core/report/test_email.py index a94aeba1..d6bd0b4f 100644 --- a/tests/ahriman/core/report/test_email.py +++ b/tests/ahriman/core/report/test_email.py @@ -23,8 +23,8 @@ def test_send_auth(configuration: Configuration, mocker: MockerFixture) -> None: """ must send an email with attachment with auth """ - configuration.set("email", "user", "username") - configuration.set("email", "password", "password") + configuration.set_option("email", "user", "username") + configuration.set_option("email", "password", "password") smtp_mock = mocker.patch("smtplib.SMTP") report = Email("x86_64", configuration) @@ -36,7 +36,7 @@ def test_send_auth_no_password(configuration: Configuration, mocker: MockerFixtu """ must send an email with attachment without auth if no password supplied """ - configuration.set("email", "user", "username") + configuration.set_option("email", "user", "username") smtp_mock = mocker.patch("smtplib.SMTP") report = Email("x86_64", configuration) @@ -48,7 +48,7 @@ def test_send_auth_no_user(configuration: Configuration, mocker: MockerFixture) """ must send an email with attachment without auth if no user supplied """ - configuration.set("email", "password", "password") + configuration.set_option("email", "password", "password") smtp_mock = mocker.patch("smtplib.SMTP") report = Email("x86_64", configuration) @@ -60,7 +60,7 @@ def test_send_ssl_tls(configuration: Configuration, mocker: MockerFixture) -> No """ must send an email with attachment with ssl/tls """ - configuration.set("email", "ssl", "ssl") + configuration.set_option("email", "ssl", "ssl") smtp_mock = mocker.patch("smtplib.SMTP_SSL") report = Email("x86_64", configuration) @@ -75,7 +75,7 @@ def test_send_starttls(configuration: Configuration, mocker: MockerFixture) -> N """ must send an email with attachment with starttls """ - configuration.set("email", "ssl", "starttls") + configuration.set_option("email", "ssl", "starttls") smtp_mock = mocker.patch("smtplib.SMTP") report = Email("x86_64", configuration) @@ -109,7 +109,7 @@ def test_generate_no_empty(configuration: Configuration, package_ahriman: Packag """ must not generate report with built packages if no_empty_report is set """ - configuration.set("email", "no_empty_report", "yes") + configuration.set_option("email", "no_empty_report", "yes") send_mock = mocker.patch("ahriman.core.report.email.Email._send") report = Email("x86_64", configuration) @@ -122,7 +122,7 @@ def test_generate_no_empty_with_built(configuration: Configuration, package_ahri """ must generate report with built packages if no_empty_report is set """ - configuration.set("email", "no_empty_report", "yes") + configuration.set_option("email", "no_empty_report", "yes") send_mock = mocker.patch("ahriman.core.report.email.Email._send") report = Email("x86_64", configuration) diff --git a/tests/ahriman/core/status/conftest.py b/tests/ahriman/core/status/conftest.py index e12c2365..7b0bddaf 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,11 @@ def client() -> Client: @pytest.fixture -def web_client() -> WebClient: +def web_client(configuration: Configuration) -> WebClient: """ fixture for web client + :param configuration: configuration fixture :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_client.py b/tests/ahriman/core/status/test_client.py index ba677d3b..a034a0fe 100644 --- a/tests/ahriman/core/status/test_client.py +++ b/tests/ahriman/core/status/test_client.py @@ -17,10 +17,18 @@ 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") + configuration.set_option("web", "host", "localhost") + configuration.set_option("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_option("web", "address", "http://localhost:8080") assert isinstance(Client.load(configuration), WebClient) diff --git a/tests/ahriman/core/status/test_web_client.py b/tests/ahriman/core/status/test_web_client.py index fd8fb2cb..36bc1c7b 100644 --- a/tests/ahriman/core/status/test_web_client.py +++ b/tests/ahriman/core/status/test_web_client.py @@ -5,41 +5,97 @@ 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: """ 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_parse_address(configuration: Configuration) -> None: + """ + must extract address correctly + """ + configuration.set_option("web", "host", "localhost") + configuration.set_option("web", "port", "8080") + assert WebClient.parse_address(configuration) == "http://localhost:8080" + + configuration.set_option("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 + """ + 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 +106,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 +114,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 +127,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 +139,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 +147,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 +160,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 +176,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 +187,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 +195,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 +207,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 +218,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 +226,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 +234,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 +244,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 +252,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 +260,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 +270,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 +278,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 +286,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 +296,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 +304,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) diff --git a/tests/ahriman/core/test_configuration.py b/tests/ahriman/core/test_configuration.py index c3c0c5d2..5c015bc4 100644 --- a/tests/ahriman/core/test_configuration.py +++ b/tests/ahriman/core/test_configuration.py @@ -9,7 +9,7 @@ def test_from_path(mocker: MockerFixture) -> None: """ must load configuration """ - read_mock = mocker.patch("configparser.RawConfigParser.read") + read_mock = mocker.patch("ahriman.core.configuration.Configuration.read") load_includes_mock = mocker.patch("ahriman.core.configuration.Configuration.load_includes") load_logging_mock = mocker.patch("ahriman.core.configuration.Configuration.load_logging") path = Path("path") @@ -33,7 +33,7 @@ def test_absolute_path_for_absolute(configuration: Configuration) -> None: must not change path for absolute path in settings """ path = Path("/a/b/c") - configuration.set("build", "path", str(path)) + configuration.set_option("build", "path", str(path)) assert configuration.getpath("build", "path") == path @@ -42,7 +42,7 @@ def test_absolute_path_for_relative(configuration: Configuration) -> None: must prepend root path to relative path """ path = Path("a") - configuration.set("build", "path", str(path)) + configuration.set_option("build", "path", str(path)) result = configuration.getpath("build", "path") assert result.is_absolute() assert result.parent == configuration.path.parent @@ -61,8 +61,7 @@ def test_dump_architecture_specific(configuration: Configuration) -> None: dump must contain architecture specific settings """ section = configuration.section_name("build", "x86_64") - configuration.add_section(section) - configuration.set(section, "archbuild_flags", "hello flag") + configuration.set_option(section, "archbuild_flags", "hello flag") configuration.merge_sections("x86_64") dump = configuration.dump() @@ -76,7 +75,7 @@ def test_getlist(configuration: Configuration) -> None: """ must return list of string correctly """ - configuration.set("build", "test_list", "a b c") + configuration.set_option("build", "test_list", "a b c") assert configuration.getlist("build", "test_list") == ["a", "b", "c"] @@ -85,7 +84,7 @@ def test_getlist_empty(configuration: Configuration) -> None: must return list of string correctly for non-existing option """ assert configuration.getlist("build", "test_list") == [] - configuration.set("build", "test_list", "") + configuration.set_option("build", "test_list", "") assert configuration.getlist("build", "test_list") == [] @@ -93,7 +92,7 @@ def test_getlist_single(configuration: Configuration) -> None: """ must return list of strings for single string """ - configuration.set("build", "test_list", "a") + configuration.set_option("build", "test_list", "a") assert configuration.getlist("build", "test_list") == ["a"] @@ -101,7 +100,7 @@ def test_load_includes_missing(configuration: Configuration) -> None: """ must not fail if not include directory found """ - configuration.set("settings", "include", "path") + configuration.set_option("settings", "include", "path") configuration.load_includes() @@ -144,8 +143,23 @@ def test_merge_sections_missing(configuration: Configuration) -> None: """ section = configuration.section_name("build", "x86_64") configuration.remove_section("build") - configuration.add_section(section) - configuration.set(section, "key", "value") + configuration.set_option(section, "key", "value") configuration.merge_sections("x86_64") assert configuration.get("build", "key") == "value" + + +def test_set_option(configuration: Configuration) -> None: + """ + must set option correctly + """ + configuration.set_option("settings", "option", "value") + assert configuration.get("settings", "option") == "value" + + +def test_set_option_new_section(configuration: Configuration) -> None: + """ + must set option correctly even if no section found + """ + configuration.set_option("section", "option", "value") + assert configuration.get("section", "option") == "value" diff --git a/tests/ahriman/core/upload/test_s3.py b/tests/ahriman/core/upload/test_s3.py index 4eae1d44..a8d8d771 100644 --- a/tests/ahriman/core/upload/test_s3.py +++ b/tests/ahriman/core/upload/test_s3.py @@ -61,6 +61,8 @@ def test_get_local_files(s3: S3, resource_path_root: Path) -> None: Path("models/package_yay_srcinfo"), Path("web/templates/search-line.jinja2"), Path("web/templates/build-status.jinja2"), + Path("web/templates/login-form.jinja2"), + Path("web/templates/login-form-hide.jinja2"), Path("web/templates/repo-index.jinja2"), Path("web/templates/sorttable.jinja2"), Path("web/templates/style.jinja2"), diff --git a/tests/ahriman/models/test_auth_settings.py b/tests/ahriman/models/test_auth_settings.py new file mode 100644 index 00000000..c5d6ed9b --- /dev/null +++ b/tests/ahriman/models/test_auth_settings.py @@ -0,0 +1,36 @@ +import pytest + +from ahriman.core.exceptions import InvalidOption +from ahriman.models.auth_settings import AuthSettings + + +def test_from_option_invalid() -> None: + """ + must raise exception on invalid option + """ + with pytest.raises(InvalidOption, match=".* `invalid`$"): + AuthSettings.from_option("invalid") + + +def test_from_option_valid() -> None: + """ + must return value from valid options + """ + assert AuthSettings.from_option("disabled") == AuthSettings.Disabled + assert AuthSettings.from_option("DISABLED") == AuthSettings.Disabled + assert AuthSettings.from_option("no") == AuthSettings.Disabled + assert AuthSettings.from_option("NO") == AuthSettings.Disabled + + assert AuthSettings.from_option("configuration") == AuthSettings.Configuration + assert AuthSettings.from_option("ConFigUration") == AuthSettings.Configuration + assert AuthSettings.from_option("mapping") == AuthSettings.Configuration + assert AuthSettings.from_option("MAPPing") == AuthSettings.Configuration + + +def test_is_enabled() -> None: + """ + must mark as disabled authorization for disabled and enabled otherwise + """ + assert not AuthSettings.Disabled.is_enabled + for option in filter(lambda o: o != AuthSettings.Disabled, AuthSettings): + assert option.is_enabled diff --git a/tests/ahriman/models/test_user.py b/tests/ahriman/models/test_user.py new file mode 100644 index 00000000..7fd29048 --- /dev/null +++ b/tests/ahriman/models/test_user.py @@ -0,0 +1,75 @@ +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_hash_password(user: User) -> None: + """ + must generate and validate user password + """ + current_password = user.password + user.password = user.hash_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_generate_password() -> None: + """ + must generate password with specified length + """ + password = User.generate_password(16) + assert password + assert len(password) == 16 + + password = User.generate_password(42) + assert password + assert len(password) == 42 + + +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..57d26139 100644 --- a/tests/ahriman/web/conftest.py +++ b/tests/ahriman/web/conftest.py @@ -3,7 +3,10 @@ import pytest from aiohttp import web from pytest_mock import MockerFixture +import ahriman.core.auth.helpers + from ahriman.core.configuration import Configuration +from ahriman.models.user import User from ahriman.web.web import setup_service @@ -15,5 +18,26 @@ def application(configuration: Configuration, mocker: MockerFixture) -> web.Appl :param mocker: mocker object :return: application test instance """ + mocker.patch.object(ahriman.core.auth.helpers, "_has_aiohttp_security", False) mocker.patch("pathlib.Path.mkdir") return setup_service("x86_64", configuration) + + +@pytest.fixture +def application_with_auth(configuration: Configuration, user: User, mocker: MockerFixture) -> web.Application: + """ + application fixture with auth enabled + :param configuration: configuration fixture + :param user: user descriptor fixture + :param mocker: mocker object + :return: application test instance + """ + configuration.set_option("auth", "target", "configuration") + mocker.patch.object(ahriman.core.auth.helpers, "_has_aiohttp_security", True) + mocker.patch("pathlib.Path.mkdir") + application = setup_service("x86_64", configuration) + + generated = User(user.username, user.hash_password(user.password, application["validator"].salt), user.access) + application["validator"]._users[generated.username] = generated + + return application diff --git a/tests/ahriman/web/middlewares/conftest.py b/tests/ahriman/web/middlewares/conftest.py index ca968d97..716c29cd 100644 --- a/tests/ahriman/web/middlewares/conftest.py +++ b/tests/ahriman/web/middlewares/conftest.py @@ -2,8 +2,12 @@ import pytest from collections import namedtuple +from ahriman.core.auth.auth import Auth +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 +16,17 @@ 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 + """ + configuration.set_option("auth", "target", "configuration") + validator = Auth.load(configuration) + policy = AuthorizationPolicy(validator) + 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..a89f35f6 --- /dev/null +++ b/tests/ahriman/web/middlewares/test_auth_handler.py @@ -0,0 +1,100 @@ +from aiohttp import web +from pytest_mock import MockerFixture +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from ahriman.core.auth.auth import Auth +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) -> None: + """ + must call validator check + """ + authorization_policy.validator = MagicMock() + authorization_policy.validator.verify_access.return_value = True + + assert await authorization_policy.permits(user.username, user.access, "/endpoint") + authorization_policy.validator.verify_access.assert_called_with(user.username, user.access, "/endpoint") + + +async def test_auth_handler_api(aiohttp_request: Any, auth: Auth, mocker: MockerFixture) -> None: + """ + must ask for status permission for api calls + """ + aiohttp_request = aiohttp_request._replace(path="/api") + request_handler = AsyncMock() + mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False) + check_permission_mock = mocker.patch("aiohttp_security.check_permission") + + handler = auth_handler(auth) + 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, auth: Auth, 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() + mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False) + check_permission_mock = mocker.patch("aiohttp_security.check_permission") + + handler = auth_handler(auth) + 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, auth: Auth, 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() + mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False) + check_permission_mock = mocker.patch("aiohttp_security.check_permission") + + handler = auth_handler(auth) + 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, auth: Auth, 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() + mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False) + check_permission_mock = mocker.patch("aiohttp_security.check_permission") + + handler = auth_handler(auth) + await handler(aiohttp_request, request_handler) + check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Write, aiohttp_request.path) + + +def test_setup_auth( + application_with_auth: web.Application, + configuration: Configuration, + mocker: MockerFixture) -> None: + """ + must setup authorization + """ + aiohttp_security_setup_mock = mocker.patch("aiohttp_security.setup") + application = setup_auth(application_with_auth, 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..a3372a54 100644 --- a/tests/ahriman/web/test_web.py +++ b/tests/ahriman/web/test_web.py @@ -35,9 +35,22 @@ def test_run(application: web.Application, mocker: MockerFixture) -> None: must run application """ port = 8080 - application["configuration"].set("web", "port", str(port)) + application["configuration"].set_option("web", "port", str(port)) run_application_mock = mocker.patch("aiohttp.web.run_app") 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_option("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/conftest.py b/tests/ahriman/web/views/conftest.py index 29ec18d0..8ae6bcc9 100644 --- a/tests/ahriman/web/views/conftest.py +++ b/tests/ahriman/web/views/conftest.py @@ -20,3 +20,18 @@ def client(application: web.Application, loop: BaseEventLoop, """ mocker.patch("pathlib.Path.iterdir", return_value=[]) return loop.run_until_complete(aiohttp_client(application)) + + +@pytest.fixture +def client_with_auth(application_with_auth: web.Application, loop: BaseEventLoop, + aiohttp_client: Any, mocker: MockerFixture) -> TestClient: + """ + web client fixture with full authorization functions + :param application_with_auth: application fixture + :param loop: context event loop + :param aiohttp_client: aiohttp client fixture + :param mocker: mocker object + :return: web client test instance + """ + mocker.patch("pathlib.Path.iterdir", return_value=[]) + return loop.run_until_complete(aiohttp_client(application_with_auth)) diff --git a/tests/ahriman/web/views/test_view_index.py b/tests/ahriman/web/views/test_view_index.py index 1d2099fa..cda12b7b 100644 --- a/tests/ahriman/web/views/test_view_index.py +++ b/tests/ahriman/web/views/test_view_index.py @@ -1,19 +1,28 @@ from pytest_aiohttp import TestClient -async def test_get(client: TestClient) -> None: +async def test_get(client_with_auth: TestClient) -> None: """ must generate status page correctly (/) """ + response = await client_with_auth.get("/") + assert response.status == 200 + assert await response.text() + + +async def test_get_index(client_with_auth: TestClient) -> None: + """ + must generate status page correctly (/index.html) + """ + response = await client_with_auth.get("/index.html") + assert response.status == 200 + assert await response.text() + + +async def test_get_without_auth(client: TestClient) -> None: + """ + must use dummy authorized_userid function in case if no security library installed + """ response = await client.get("/") assert response.status == 200 assert await response.text() - - -async def test_get_index(client: TestClient) -> None: - """ - must generate status page correctly (/index.html) - """ - response = await client.get("/index.html") - assert response.status == 200 - assert await response.text() 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..289b1fc2 --- /dev/null +++ b/tests/ahriman/web/views/test_view_login.py @@ -0,0 +1,41 @@ +from aiohttp.test_utils import TestClient +from pytest_mock import MockerFixture + +from ahriman.models.user import User + + +async def test_post(client_with_auth: TestClient, user: User, mocker: MockerFixture) -> None: + """ + must login user correctly + """ + payload = {"username": user.username, "password": user.password} + remember_mock = mocker.patch("aiohttp_security.remember") + + post_response = await client_with_auth.post("/login", json=payload) + assert post_response.status == 200 + + post_response = await client_with_auth.post("/login", data=payload) + assert post_response.status == 200 + + remember_mock.assert_called() + + +async def test_post_skip(client: TestClient, user: User) -> None: + """ + must process if no auth configured + """ + payload = {"username": user.username, "password": user.password} + post_response = await client.post("/login", json=payload) + assert post_response.status == 200 + + +async def test_post_unauthorized(client_with_auth: TestClient, user: User, mocker: MockerFixture) -> None: + """ + must return unauthorized on invalid auth + """ + payload = {"username": user.username, "password": ""} + remember_mock = mocker.patch("aiohttp_security.remember") + + post_response = await client_with_auth.post("/login", json=payload) + assert post_response.status == 401 + remember_mock.assert_not_called() 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..d9135c4a --- /dev/null +++ b/tests/ahriman/web/views/test_view_logout.py @@ -0,0 +1,35 @@ +from aiohttp.test_utils import TestClient +from aiohttp.web import HTTPUnauthorized +from pytest_mock import MockerFixture + + +async def test_post(client_with_auth: TestClient, mocker: MockerFixture) -> None: + """ + must logout user correctly + """ + mocker.patch("aiohttp_security.check_authorized") + forget_mock = mocker.patch("aiohttp_security.forget") + + post_response = await client_with_auth.post("/logout") + assert post_response.status == 200 + forget_mock.assert_called_once() + + +async def test_post_unauthorized(client_with_auth: TestClient, mocker: MockerFixture) -> None: + """ + must raise exception if unauthorized + """ + mocker.patch("aiohttp_security.check_authorized", side_effect=HTTPUnauthorized()) + forget_mock = mocker.patch("aiohttp_security.forget") + + post_response = await client_with_auth.post("/logout") + assert post_response.status == 401 + forget_mock.assert_not_called() + + +async def test_post_disabled(client: TestClient) -> None: + """ + must raise exception if auth is disabled + """ + post_response = await client.post("/logout") + assert post_response.status == 200 diff --git a/tests/testresources/core/ahriman.ini b/tests/testresources/core/ahriman.ini index 08c79648..d28582ea 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