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 @@
+ {% 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