Auth support (#25)

* initial auth implementation

* add create user parser

* add tests

* update dependencies list

* add login annd logout to index also improve auth

* realworld fixes

* add method set_option to Configuration and also use it everywhere
* split CreateUser handler to additional read method
* check user duplicate on auth mapping read
* generate salt by using passlib instead of random.choice
* case-insensetive usernames
* update dependencies
* update configuration reference
* improve tests

* fix codefactor errors

* hide fields if authorization is enabled, but no auth supplied

* add settings object for auth provider

* readme update
This commit is contained in:
Evgenii Alekseev 2021-09-02 23:36:00 +03:00 committed by GitHub
parent 3922c55464
commit e63cb509f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 2200 additions and 184 deletions

View File

@ -18,6 +18,23 @@ libalpm and AUR related configuration.
* `repositories` - list of pacman repositories, space separated list of strings, required. * `repositories` - list of pacman repositories, space separated list of strings, required.
* `root` - root for alpm library, string, 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:*` groups
Build related configuration. Group name must refer to architecture, e.g. it should be `build:x86_64` for x86_64 architecture. 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. 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. * `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. * `port` - port to bind, int, optional.
* `templates` - path to templates directory, string, required. * `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.

View File

@ -7,13 +7,13 @@ Wrapper for managing custom repository inspired by [repo-scripts](https://github
## Features ## Features
* Install-configure-forget manager for own repository * Install-configure-forget manager for own repository.
* Multi-architecture support * Multi-architecture support.
* VCS packages support * VCS packages support.
* Sign support with gpg (repository, package, per package settings) * Sign support with gpg (repository, package, per package settings).
* Synchronization to remote services (rsync, s3) and report generation (html) * Synchronization to remote services (rsync, s3) and report generation (html).
* Dependency manager * Dependency manager.
* Repository status interface * Repository status interface with optional authorization.
## Installation and run ## Installation and run

View File

@ -7,15 +7,18 @@ pkgdesc="ArcHlinux ReposItory MANager"
arch=('any') arch=('any')
url="https://github.com/arcan1s/ahriman" url="https://github.com/arcan1s/ahriman"
license=('GPL3') license=('GPL3')
depends=('devtools' 'git' 'pyalpm' 'python-aur' 'python-srcinfo') depends=('devtools' 'git' 'pyalpm' 'python-aur' 'python-passlib' 'python-srcinfo')
makedepends=('python-argparse-manpage' 'python-pip') makedepends=('python-pip')
optdepends=('breezy: -bzr packages support' optdepends=('breezy: -bzr packages support'
'darcs: -darcs packages support' 'darcs: -darcs packages support'
'gnupg: package and repository sign' 'gnupg: package and repository sign'
'mercurial: -hg packages support' 'mercurial: -hg packages support'
'python-aiohttp: web server' 'python-aiohttp: web server'
'python-aiohttp-jinja2: 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-boto3: sync to s3'
'python-cryptography: web server with authorization'
'python-jinja: html report generation' 'python-jinja: html report generation'
'rsync: sync by using rsync' 'rsync: sync by using rsync'
'subversion: -svn packages support') 'subversion: -svn packages support')

View File

@ -8,6 +8,9 @@ database = /var/lib/pacman
repositories = core extra community multilib repositories = core extra community multilib
root = / root = /
[auth]
target = disabled
[build] [build]
archbuild_flags = archbuild_flags =
build_command = extra-x86_64-build build_command = extra-x86_64-build

View File

@ -12,11 +12,15 @@
<body> <body>
<div class="root"> <div class="root">
<h1>ahriman <h1>ahriman
{% if authorized %}
<img src="https://img.shields.io/badge/version-{{ version }}-informational" alt="{{ version }}"> <img src="https://img.shields.io/badge/version-{{ version }}-informational" alt="{{ version }}">
<img src="https://img.shields.io/badge/architecture-{{ architecture }}-informational" alt="{{ architecture }}"> <img src="https://img.shields.io/badge/architecture-{{ architecture }}-informational" alt="{{ architecture }}">
<img src="https://img.shields.io/badge/service%20status-{{ service.status }}-{{ service.status_color }}" alt="{{ service.status }}" title="{{ service.timestamp }}"> <img src="https://img.shields.io/badge/service%20status-{{ service.status }}-{{ service.status_color }}" alt="{{ service.status }}" title="{{ service.timestamp }}">
{% endif %}
</h1> </h1>
{% include "login-form.jinja2" %}
{% include "login-form-hide.jinja2" %}
{% include "search-line.jinja2" %} {% include "search-line.jinja2" %}
<section class="element"> <section class="element">
@ -29,6 +33,7 @@
<th>status</th> <th>status</th>
</tr> </tr>
{% if authorized %}
{% for package in packages %} {% for package in packages %}
<tr class="package"> <tr class="package">
<td class="include-search"><a href="{{ package.web_url }}" title="{{ package.base }}">{{ package.base }}</a></td> <td class="include-search"><a href="{{ package.web_url }}" title="{{ package.base }}">{{ package.base }}</a></td>
@ -38,6 +43,11 @@
<td class="status package-{{ package.status }}">{{ package.status }}</td> <td class="status package-{{ package.status }}">{{ package.status }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% else %}
<tr class="package">
<td colspan="100%">In order to see statuses you must login first</td>
</tr>
{% endif %}
</table> </table>
</section> </section>
@ -46,6 +56,17 @@
<li><a href="https://github.com/arcan1s/ahriman" title="sources">ahriman</a></li> <li><a href="https://github.com/arcan1s/ahriman" title="sources">ahriman</a></li>
<li><a href="https://github.com/arcan1s/ahriman/releases" title="releases list">releases</a></li> <li><a href="https://github.com/arcan1s/ahriman/releases" title="releases list">releases</a></li>
<li><a href="https://github.com/arcan1s/ahriman/issues" title="issues tracker">report a bug</a></li> <li><a href="https://github.com/arcan1s/ahriman/issues" title="issues tracker">report a bug</a></li>
{% if auth_enabled %}
<li class="right">
{% if auth_username is not none %}
<form action="/logout" method="post">
<button class="login" type="submit">logout ({{ auth_username }})</button>
</form>
{% else %}
<button class="login" onclick="document.getElementById('login-form').style.display='block'">login</button>
{% endif %}
</li>
{% endif %}
</ul> </ul>
</footer> </footer>
</div> </div>

View File

@ -0,0 +1,9 @@
<script>
const modal = document.getElementById('login-form');
window.onclick = function(event) {
if (event.target === modal) {
modal.style.display = "none";
}
}
</script>

View File

@ -0,0 +1,18 @@
{#idea is from here https://www.w3schools.com/howto/howto_css_login_form.asp#}
<div id="login-form" class="modal-login-form">
<form class="modal-login-form-content animate" action="/login" method="post">
<div class="login-container">
<label for="username"><b>username</b></label>
<input type="text" placeholder="enter username" name="username" required>
<label for="password"><b>password</b></label>
<input type="password" placeholder="enter password" name="password" required>
<button class="login" type="submit">login</button>
</div>
<div class="login-container">
<button class="cancel" onclick="document.getElementById('login-form').style.display='none'">cancel</button>
</div>
</form>
</div>

View File

@ -40,6 +40,7 @@
width: inherit; width: inherit;
} }
/* table description */
th, td { th, td {
padding: 5px; padding: 5px;
} }
@ -103,6 +104,7 @@
background-color: rgba(var(--color-success), 1.0); background-color: rgba(var(--color-success), 1.0);
} }
/* navigation footer description */
ul.navigation { ul.navigation {
list-style-type: none; list-style-type: none;
margin: 0; margin: 0;
@ -115,11 +117,8 @@
float: left; float: left;
} }
ul.navigation li.status { ul.navigation li.right {
display: block; float: right;
text-align: center;
text-decoration: none;
padding: 14px 16px;
} }
ul.navigation li a { ul.navigation li a {
@ -131,6 +130,86 @@
} }
ul.navigation li a:hover { 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)}
} }
</style> </style>

View File

@ -3,6 +3,3 @@ test = pytest
[tool:pytest] [tool:pytest]
addopts = --cov=ahriman --cov-report term-missing:skip-covered --pspec addopts = --cov=ahriman --cov-report term-missing:skip-covered --pspec
[build_manpages]
manpages = man/ahriman.1:module=ahriman.application.ahriman:function=_parser

View File

@ -1,11 +1,10 @@
from build_manpages import build_manpages
from pathlib import Path from pathlib import Path
from setuptools import setup, find_packages from setuptools import setup, find_packages
from typing import Any, Dict from typing import Any, Dict
metadata_path = Path(__file__).resolve().parent / "src/ahriman/version.py" 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: with metadata_path.open() as metadata_file:
exec(metadata_file.read(), metadata) # pylint: disable=exec-used exec(metadata_file.read(), metadata) # pylint: disable=exec-used
@ -31,6 +30,7 @@ setup(
], ],
install_requires=[ install_requires=[
"aur", "aur",
"passlib",
"pyalpm", "pyalpm",
"requests", "requests",
"srcinfo", "srcinfo",
@ -97,10 +97,10 @@ setup(
"Jinja2", "Jinja2",
"aiohttp", "aiohttp",
"aiohttp_jinja2", "aiohttp_jinja2",
"aiohttp_session",
"aiohttp_security",
"cryptography",
"passlib",
], ],
}, },
cmdclass={
"build_manpages": build_manpages.build_manpages,
}
) )

View File

@ -30,6 +30,8 @@ from ahriman.models.sign_settings import SignSettings
# pylint thinks it is bad idea, but get the fuck off # 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 SubParserAction = argparse._SubParsersAction # pylint: disable=protected-access
@ -61,6 +63,7 @@ def _parser() -> argparse.ArgumentParser:
_set_check_parser(subparsers) _set_check_parser(subparsers)
_set_clean_parser(subparsers) _set_clean_parser(subparsers)
_set_config_parser(subparsers) _set_config_parser(subparsers)
_set_create_user_parser(subparsers)
_set_init_parser(subparsers) _set_init_parser(subparsers)
_set_key_import_parser(subparsers) _set_key_import_parser(subparsers)
_set_rebuild_parser(subparsers) _set_rebuild_parser(subparsers)
@ -138,6 +141,30 @@ def _set_config_parser(root: SubParserAction) -> argparse.ArgumentParser:
return parser 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: def _set_init_parser(root: SubParserAction) -> argparse.ArgumentParser:
""" """
add parser for init subcommand add parser for init subcommand

View File

@ -21,6 +21,7 @@ from ahriman.application.handlers.handler import Handler
from ahriman.application.handlers.add import Add from ahriman.application.handlers.add import Add
from ahriman.application.handlers.clean import Clean 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.dump import Dump
from ahriman.application.handlers.init import Init from ahriman.application.handlers.init import Init
from ahriman.application.handlers.key_import import KeyImport from ahriman.application.handlers.key_import import KeyImport

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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)

View File

@ -18,7 +18,6 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
import argparse import argparse
import configparser
from pathlib import Path from pathlib import Path
from typing import Type from typing import Type
@ -79,25 +78,20 @@ class Setup(Handler):
:param repository: repository name :param repository: repository name
:param include_path: path to directory with configuration includes :param include_path: path to directory with configuration includes
""" """
configuration = configparser.ConfigParser() configuration = Configuration()
section = Configuration.section_name("build", architecture) section = Configuration.section_name("build", architecture)
configuration.add_section(section) configuration.set_option(section, "build_command", str(Setup.build_command(args.build_command, architecture)))
configuration.set(section, "build_command", str(Setup.build_command(args.build_command, architecture))) configuration.set_option("repository", "name", repository)
configuration.add_section("repository")
configuration.set("repository", "name", repository)
if args.sign_key is not None: if args.sign_key is not None:
section = Configuration.section_name("sign", architecture) section = Configuration.section_name("sign", architecture)
configuration.add_section(section) configuration.set_option(section, "target", " ".join([target.name.lower() for target in args.sign_target]))
configuration.set(section, "target", " ".join([target.name.lower() for target in args.sign_target])) configuration.set_option(section, "key", args.sign_key)
configuration.set(section, "key", args.sign_key)
if args.web_port is not None: if args.web_port is not None:
section = Configuration.section_name("web", architecture) section = Configuration.section_name("web", architecture)
configuration.add_section(section) configuration.set_option(section, "port", str(args.web_port))
configuration.set(section, "port", str(args.web_port))
target = include_path / "setup-overrides.ini" target = include_path / "setup-overrides.ini"
with target.open("w") as ahriman_configuration: with target.open("w") as ahriman_configuration:
@ -115,7 +109,7 @@ class Setup(Handler):
:param repository: repository name :param repository: repository name
:param paths: repository paths instance :param paths: repository paths instance
""" """
configuration = configparser.ConfigParser() configuration = Configuration()
# preserve case # preserve case
# stupid mypy thinks that it is impossible # stupid mypy thinks that it is impossible
configuration.optionxform = lambda key: key # type: ignore configuration.optionxform = lambda key: key # type: ignore
@ -125,17 +119,15 @@ class Setup(Handler):
configuration.read(source) configuration.read(source)
# set our architecture now # set our architecture now
configuration.set("options", "Architecture", architecture) configuration.set_option("options", "Architecture", architecture)
# add multilib # add multilib
if not no_multilib: if not no_multilib:
configuration.add_section("multilib") configuration.set_option("multilib", "Include", str(Setup.MIRRORLIST_PATH))
configuration.set("multilib", "Include", str(Setup.MIRRORLIST_PATH))
# add repository itself # add repository itself
configuration.add_section(repository) configuration.set_option(repository, "SigLevel", "Optional TrustAll") # we don't care
configuration.set(repository, "SigLevel", "Optional TrustAll") # we don't care configuration.set_option(repository, "Server", f"file://{paths.repository}")
configuration.set(repository, "Server", f"file://{paths.repository}")
target = source.parent / f"pacman-{prefix}-{architecture}.conf" target = source.parent / f"pacman-{prefix}-{architecture}.conf"
with target.open("w") as devtools_configuration: with target.open("w") as devtools_configuration:

View File

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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)

View File

@ -78,14 +78,14 @@ class Configuration(configparser.RawConfigParser):
return config return config
@staticmethod @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 section: section name
:param architecture: repository architecture :param suffix: session suffix, e.g. repository architecture
:return: correct section name for repository specific section :return: correct section name for repository specific section
""" """
return f"{section}:{architecture}" return f"{section}:{suffix}"
def dump(self) -> Dict[str, Dict[str, str]]: def dump(self) -> Dict[str, Dict[str, str]]:
""" """
@ -170,18 +170,27 @@ class Configuration(configparser.RawConfigParser):
:param architecture: repository architecture :param architecture: repository architecture
""" """
for section in self.ARCHITECTURE_SPECIFIC_SECTIONS: for section in self.ARCHITECTURE_SPECIFIC_SECTIONS:
if not self.has_section(section):
self.add_section(section) # add section if not exists
# get overrides # get overrides
specific = self.section_name(section, architecture) specific = self.section_name(section, architecture)
if self.has_section(specific): if self.has_section(specific):
# if there is no such section it means that there is no overrides for this arch # 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 # but we anyway will have to delete sections for others archs
for key, value in self[specific].items(): for key, value in self[specific].items():
self.set(section, key, value) self.set_option(section, key, value)
# remove any arch specific section # remove any arch specific section
for foreign in self.sections(): for foreign in self.sections():
# we would like to use lambda filter here, but pylint is too dumb # we would like to use lambda filter here, but pylint is too dumb
if not foreign.startswith(f"{section}:"): if not foreign.startswith(f"{section}:"):
continue continue
self.remove_section(foreign) 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)

View File

@ -45,6 +45,19 @@ class DuplicateRun(Exception):
Exception.__init__(self, "Another application instance is run") 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): class InitializeException(Exception):
""" """
base service initialization exception base service initialization exception

View File

@ -39,11 +39,12 @@ class Client:
:param configuration: configuration instance :param configuration: configuration instance
:return: client according to current settings :return: client according to current settings
""" """
address = configuration.get("web", "address", fallback=None)
host = configuration.get("web", "host", fallback=None) host = configuration.get("web", "host", fallback=None)
port = configuration.getint("web", "port", 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 from ahriman.core.status.web_client import WebClient
return WebClient(host, port) return WebClient(configuration)
return cls() return cls()
def add(self, package: Package, status: BuildStatusEnum) -> None: def add(self, package: Package, status: BuildStatusEnum) -> None:

View File

@ -22,37 +22,92 @@ import requests
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from ahriman.core.configuration import Configuration
from ahriman.core.status.client import Client from ahriman.core.status.client import Client
from ahriman.core.util import exception_response_text from ahriman.core.util import exception_response_text
from ahriman.models.build_status import BuildStatusEnum, BuildStatus from ahriman.models.build_status import BuildStatusEnum, BuildStatus
from ahriman.models.internal_status import InternalStatus from ahriman.models.internal_status import InternalStatus
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.user import User
class WebClient(Client): class WebClient(Client):
""" """
build status reporter web client build status reporter web client
:ivar host: host of web service :ivar address: address of the web service
:ivar logger: class logger :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 default constructor
:param host: host of web service :param configuration: configuration instance
:param port: port of web service
""" """
self.logger = logging.getLogger("http") self.logger = logging.getLogger("http")
self.host = host self.address = self.parse_address(configuration)
self.port = port 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: def _ahriman_url(self) -> str:
""" """
url generator
:return: full url for web service for ahriman service itself :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: def _package_url(self, base: str = "") -> str:
""" """
@ -60,14 +115,7 @@ class WebClient(Client):
:param base: package base to generate url :param base: package base to generate url
:return: full url of web service for specific package base :return: full url of web service for specific package base
""" """
return f"http://{self.host}:{self.port}/api/v1/packages/{base}" return f"{self.address}/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"
def add(self, package: Package, status: BuildStatusEnum) -> None: def add(self, package: Package, status: BuildStatusEnum) -> None:
""" """
@ -81,7 +129,7 @@ class WebClient(Client):
} }
try: 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() response.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.exception("could not add %s: %s", package.base, exception_response_text(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 :return: list of current package description and status if it has been found
""" """
try: try:
response = requests.get(self._package_url(base or "")) response = self.__session.get(self._package_url(base or ""))
response.raise_for_status() response.raise_for_status()
status_json = response.json() status_json = response.json()
@ -115,7 +163,7 @@ class WebClient(Client):
:return: current internal (web) service status :return: current internal (web) service status
""" """
try: try:
response = requests.get(self._status_url()) response = self.__session.get(self._status_url)
response.raise_for_status() response.raise_for_status()
status_json = response.json() status_json = response.json()
@ -132,7 +180,7 @@ class WebClient(Client):
:return: current ahriman status :return: current ahriman status
""" """
try: try:
response = requests.get(self._ahriman_url()) response = self.__session.get(self._ahriman_url)
response.raise_for_status() response.raise_for_status()
status_json = response.json() status_json = response.json()
@ -149,7 +197,7 @@ class WebClient(Client):
:param base: basename to remove :param base: basename to remove
""" """
try: try:
response = requests.delete(self._package_url(base)) response = self.__session.delete(self._package_url(base))
response.raise_for_status() response.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.exception("could not delete %s: %s", base, exception_response_text(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} payload = {"status": status.value}
try: try:
response = requests.post(self._package_url(base), json=payload) response = self.__session.post(self._package_url(base), json=payload)
response.raise_for_status() response.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.exception("could not update %s: %s", base, exception_response_text(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} payload = {"status": status.value}
try: try:
response = requests.post(self._ahriman_url(), json=payload) response = self.__session.post(self._ahriman_url, json=payload)
response.raise_for_status() response.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.exception("could not update service status: %s", exception_response_text(e)) self.logger.exception("could not update service status: %s", exception_response_text(e))

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

101
src/ahriman/models/user.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
#
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})"

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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"

View File

@ -17,3 +17,10 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
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]]

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -21,13 +21,11 @@ from aiohttp.web import middleware, Request
from aiohttp.web_exceptions import HTTPClientError from aiohttp.web_exceptions import HTTPClientError
from aiohttp.web_response import StreamResponse from aiohttp.web_response import StreamResponse
from logging import Logger 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) -> MiddlewareType:
def exception_handler(logger: Logger) -> Callable[[Request, HandlerType], Awaitable[StreamResponse]]:
""" """
exception handler middleware. Just log any exception (except for client ones) exception handler middleware. Just log any exception (except for client ones)
:param logger: class logger :param logger: class logger

View File

@ -21,6 +21,8 @@ from aiohttp.web import Application
from ahriman.web.views.ahriman import AhrimanView from ahriman.web.views.ahriman import AhrimanView
from ahriman.web.views.index import IndexView 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.package import PackageView
from ahriman.web.views.packages import PackagesView from ahriman.web.views.packages import PackagesView
from ahriman.web.views.status import StatusView from ahriman.web.views.status import StatusView
@ -35,6 +37,9 @@ def setup_routes(application: Application) -> None:
GET / get build status page GET / get build status page
GET /index.html same as above GET /index.html same as above
POST /login login to service
POST /logout logout from service
GET /api/v1/ahriman get current service status GET /api/v1/ahriman get current service status
POST /api/v1/ahriman update 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("/", IndexView)
application.router.add_get("/index.html", 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_get("/api/v1/ahriman", AhrimanView)
application.router.add_post("/api/v1/ahriman", AhrimanView) application.router.add_post("/api/v1/ahriman", AhrimanView)

View File

@ -46,7 +46,7 @@ class AhrimanView(BaseView):
:return: 204 on success :return: 204 on success
""" """
data = await self.request.json() data = await self.extract_data()
try: try:
status = BuildStatusEnum(data["status"]) status = BuildStatusEnum(data["status"])

View File

@ -18,7 +18,9 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import View from aiohttp.web import View
from typing import Any, Dict
from ahriman.core.auth.auth import Auth
from ahriman.core.status.watcher import Watcher from ahriman.core.status.watcher import Watcher
@ -34,3 +36,22 @@ class BaseView(View):
""" """
watcher: Watcher = self.request.app["watcher"] watcher: Watcher = self.request.app["watcher"]
return 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())

View File

@ -22,6 +22,7 @@ import aiohttp_jinja2
from typing import Any, Dict from typing import Any, Dict
from ahriman import version from ahriman import version
from ahriman.core.auth.helpers import authorized_userid
from ahriman.core.util import pretty_datetime from ahriman.core.util import pretty_datetime
from ahriman.web.views.base import BaseView 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: It uses jinja2 templates for report generation, the following variables are allowed:
architecture - repository architecture, string, required 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 packages - sorted list of packages properties, required
* base, string * base, string
* depends, sorted list of strings * depends, sorted list of strings
@ -77,8 +81,15 @@ class IndexView(BaseView):
"timestamp": pretty_datetime(self.service.status.timestamp) "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 { return {
"architecture": self.service.architecture, "architecture": self.service.architecture,
"authorized": authorized,
"auth_enabled": self.validator.enabled,
"auth_username": auth_username,
"packages": packages, "packages": packages,
"repository": self.service.repository.name, "repository": self.service.repository.name,
"service": service, "service": service,

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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()

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -74,7 +74,7 @@ class PackageView(BaseView):
:return: 204 on success :return: 204 on success
""" """
base = self.request.match_info["package"] base = self.request.match_info["package"]
data = await self.request.json() data = await self.extract_data()
try: try:
package = Package.from_json(data["package"]) if "package" in data else None package = Package.from_json(data["package"]) if "package" in data else None

View File

@ -23,6 +23,7 @@ import logging
from aiohttp import web from aiohttp import web
from ahriman.core.auth.auth import Auth
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import InitializeException from ahriman.core.exceptions import InitializeException
from ahriman.core.status.watcher import Watcher 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.logger.info("setup watcher")
application["watcher"] = Watcher(architecture, configuration) 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 return application

View File

@ -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()

View File

@ -62,19 +62,12 @@ def test_create_ahriman_configuration(args: argparse.Namespace, configuration: C
""" """
args = _default_args(args) args = _default_args(args)
mocker.patch("pathlib.Path.open") mocker.patch("pathlib.Path.open")
add_section_mock = mocker.patch("configparser.RawConfigParser.add_section") set_option_mock = mocker.patch("ahriman.core.configuration.Configuration.set_option")
set_mock = mocker.patch("configparser.RawConfigParser.set") write_mock = mocker.patch("ahriman.core.configuration.Configuration.write")
write_mock = mocker.patch("configparser.RawConfigParser.write")
command = Setup.build_command(args.build_command, "x86_64") command = Setup.build_command(args.build_command, "x86_64")
Setup.create_ahriman_configuration(args, "x86_64", args.repository, configuration.include) Setup.create_ahriman_configuration(args, "x86_64", args.repository, configuration.include)
add_section_mock.assert_has_calls([ set_option_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([
mock.call(Configuration.section_name("build", "x86_64"), "build_command", str(command)), mock.call(Configuration.section_name("build", "x86_64"), "build_command", str(command)),
mock.call("repository", "name", args.repository), mock.call("repository", "name", args.repository),
mock.call(Configuration.section_name("sign", "x86_64"), "target", 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) args = _default_args(args)
mocker.patch("pathlib.Path.open") mocker.patch("pathlib.Path.open")
mocker.patch("configparser.RawConfigParser.set") mocker.patch("ahriman.core.configuration.Configuration.set")
add_section_mock = mocker.patch("configparser.RawConfigParser.add_section") add_section_mock = mocker.patch("ahriman.core.configuration.Configuration.add_section")
write_mock = mocker.patch("configparser.RawConfigParser.write") write_mock = mocker.patch("ahriman.core.configuration.Configuration.write")
Setup.create_devtools_configuration(args.build_command, "x86_64", args.from_configuration, Setup.create_devtools_configuration(args.build_command, "x86_64", args.from_configuration,
args.no_multilib, args.repository, repository_paths) 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) args = _default_args(args)
mocker.patch("pathlib.Path.open") mocker.patch("pathlib.Path.open")
mocker.patch("configparser.RawConfigParser.set") mocker.patch("ahriman.core.configuration.Configuration.set")
add_section_mock = mocker.patch("configparser.RawConfigParser.add_section") write_mock = mocker.patch("ahriman.core.configuration.Configuration.write")
write_mock = mocker.patch("configparser.RawConfigParser.write")
Setup.create_devtools_configuration(args.build_command, "x86_64", args.from_configuration, Setup.create_devtools_configuration(args.build_command, "x86_64", args.from_configuration,
True, args.repository, repository_paths) True, args.repository, repository_paths)
add_section_mock.assert_called_once()
write_mock.assert_called_once() write_mock.assert_called_once()

View File

@ -6,6 +6,7 @@ from pytest_mock import MockerFixture
from ahriman.application.handlers import Handler from ahriman.application.handlers import Handler
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.sign_settings import SignSettings from ahriman.models.sign_settings import SignSettings
from ahriman.models.user_access import UserAccess
def test_parser(parser: argparse.ArgumentParser) -> None: def test_parser(parser: argparse.ArgumentParser) -> None:
@ -83,6 +84,28 @@ def test_subparsers_config(parser: argparse.ArgumentParser) -> None:
assert args.unsafe 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: def test_subparsers_init(parser: argparse.ArgumentParser) -> None:
""" """
init command must imply no_report init command must imply no_report

View File

@ -1,17 +1,17 @@
from unittest.mock import MagicMock
import pytest import pytest
from pathlib import Path from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from typing import Any, Type, TypeVar from typing import Any, Type, TypeVar
from ahriman.core.auth.auth import Auth
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.status.watcher import Watcher from ahriman.core.status.watcher import Watcher
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.package_description import PackageDescription from ahriman.models.package_description import PackageDescription
from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.repository_paths import RepositoryPaths
from ahriman.models.user import User
from ahriman.models.user_access import UserAccess
T = TypeVar("T") T = TypeVar("T")
@ -43,6 +43,15 @@ def anyvar(cls: Type[T], strict: bool = False) -> T:
# generic fixtures # generic fixtures
@pytest.fixture
def auth(configuration: Configuration) -> Auth:
"""
auth provider fixture
:return: auth service instance
"""
return Auth(configuration)
@pytest.fixture @pytest.fixture
def configuration(resource_path_root: Path) -> Configuration: def configuration(resource_path_root: Path) -> Configuration:
""" """
@ -158,6 +167,15 @@ def repository_paths(configuration: Configuration) -> RepositoryPaths:
root=configuration.getpath("repository", "root")) 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 @pytest.fixture
def watcher(configuration: Configuration, mocker: MockerFixture) -> Watcher: def watcher(configuration: Configuration, mocker: MockerFixture) -> Watcher:
""" """

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -2,6 +2,7 @@ import pytest
from ahriman.core.alpm.pacman import Pacman from ahriman.core.alpm.pacman import Pacman
from ahriman.core.alpm.repo import Repo 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.build_tools.task import Task
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.tree import Leaf from ahriman.core.tree import Leaf

View File

@ -23,8 +23,8 @@ def test_send_auth(configuration: Configuration, mocker: MockerFixture) -> None:
""" """
must send an email with attachment with auth must send an email with attachment with auth
""" """
configuration.set("email", "user", "username") configuration.set_option("email", "user", "username")
configuration.set("email", "password", "password") configuration.set_option("email", "password", "password")
smtp_mock = mocker.patch("smtplib.SMTP") smtp_mock = mocker.patch("smtplib.SMTP")
report = Email("x86_64", configuration) 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 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") smtp_mock = mocker.patch("smtplib.SMTP")
report = Email("x86_64", configuration) 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 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") smtp_mock = mocker.patch("smtplib.SMTP")
report = Email("x86_64", configuration) 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 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") smtp_mock = mocker.patch("smtplib.SMTP_SSL")
report = Email("x86_64", configuration) 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 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") smtp_mock = mocker.patch("smtplib.SMTP")
report = Email("x86_64", configuration) 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 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") send_mock = mocker.patch("ahriman.core.report.email.Email._send")
report = Email("x86_64", configuration) 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 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") send_mock = mocker.patch("ahriman.core.report.email.Email._send")
report = Email("x86_64", configuration) report = Email("x86_64", configuration)

View File

@ -2,6 +2,7 @@ import pytest
from typing import Any, Dict from typing import Any, Dict
from ahriman.core.configuration import Configuration
from ahriman.core.status.client import Client from ahriman.core.status.client import Client
from ahriman.core.status.web_client import WebClient from ahriman.core.status.web_client import WebClient
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
@ -40,9 +41,11 @@ def client() -> Client:
@pytest.fixture @pytest.fixture
def web_client() -> WebClient: def web_client(configuration: Configuration) -> WebClient:
""" """
fixture for web client fixture for web client
:param configuration: configuration fixture
:return: web client test instance :return: web client test instance
""" """
return WebClient("localhost", 8080) configuration.set("web", "port", 8080)
return WebClient(configuration)

View File

@ -17,10 +17,18 @@ def test_load_dummy_client(configuration: Configuration) -> None:
def test_load_full_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_option("web", "host", "localhost")
configuration.set("web", "port", "8080") 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) assert isinstance(Client.load(configuration), WebClient)

View File

@ -5,41 +5,97 @@ import requests
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from requests import Response from requests import Response
from ahriman.core.configuration import Configuration
from ahriman.core.status.web_client import WebClient from ahriman.core.status.web_client import WebClient
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.internal_status import InternalStatus from ahriman.models.internal_status import InternalStatus
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.user import User
def test_ahriman_url(web_client: WebClient) -> None: def test_ahriman_url(web_client: WebClient) -> None:
""" """
must generate service status url correctly 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.startswith(web_client.address)
assert web_client._ahriman_url().endswith("/api/v1/ahriman") 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}")
def test_status_url(web_client: WebClient) -> None: def test_status_url(web_client: WebClient) -> None:
""" """
must generate service status url correctly 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.startswith(web_client.address)
assert web_client._status_url().endswith("/api/v1/status") 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: def test_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must process package addition 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) payload = pytest.helpers.get_package_status(package_ahriman)
web_client.add(package_ahriman, BuildStatusEnum.Unknown) 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 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) 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 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) 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._content = json.dumps(response).encode("utf8")
response_obj.status_code = 200 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) result = web_client.get(None)
requests_mock.assert_called_once() 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 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) == [] 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 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) == [] 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._content = json.dumps(response).encode("utf8")
response_obj.status_code = 200 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) result = web_client.get(package_ahriman.base)
requests_mock.assert_called_once() 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._content = json.dumps(InternalStatus(architecture="x86_64").view()).encode("utf8")
response_obj.status_code = 200 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() result = web_client.get_internal()
requests_mock.assert_called_once() 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 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() 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 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() 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._content = json.dumps(BuildStatus().view()).encode("utf8")
response_obj.status_code = 200 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() result = web_client.get_self()
requests_mock.assert_called_once() 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 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 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 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 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 must process package removal
""" """
requests_mock = mocker.patch("requests.delete") requests_mock = mocker.patch("requests.Session.delete")
web_client.remove(package_ahriman.base) web_client.remove(package_ahriman.base)
requests_mock.assert_called_with(pytest.helpers.anyvar(str, True)) 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 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) 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 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) 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 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) web_client.update(package_ahriman.base, BuildStatusEnum.Unknown)
requests_mock.assert_called_with(pytest.helpers.anyvar(str, True), json={"status": BuildStatusEnum.Unknown.value}) 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 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) 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 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) 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 must process service update
""" """
requests_mock = mocker.patch("requests.post") requests_mock = mocker.patch("requests.Session.post")
web_client.update_self(BuildStatusEnum.Unknown) web_client.update_self(BuildStatusEnum.Unknown)
requests_mock.assert_called_with(pytest.helpers.anyvar(str, True), json={"status": BuildStatusEnum.Unknown.value}) 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 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) 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 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) web_client.update_self(BuildStatusEnum.Unknown)

View File

@ -9,7 +9,7 @@ def test_from_path(mocker: MockerFixture) -> None:
""" """
must load configuration 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_includes_mock = mocker.patch("ahriman.core.configuration.Configuration.load_includes")
load_logging_mock = mocker.patch("ahriman.core.configuration.Configuration.load_logging") load_logging_mock = mocker.patch("ahriman.core.configuration.Configuration.load_logging")
path = Path("path") 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 must not change path for absolute path in settings
""" """
path = Path("/a/b/c") path = Path("/a/b/c")
configuration.set("build", "path", str(path)) configuration.set_option("build", "path", str(path))
assert configuration.getpath("build", "path") == 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 must prepend root path to relative path
""" """
path = Path("a") path = Path("a")
configuration.set("build", "path", str(path)) configuration.set_option("build", "path", str(path))
result = configuration.getpath("build", "path") result = configuration.getpath("build", "path")
assert result.is_absolute() assert result.is_absolute()
assert result.parent == configuration.path.parent assert result.parent == configuration.path.parent
@ -61,8 +61,7 @@ def test_dump_architecture_specific(configuration: Configuration) -> None:
dump must contain architecture specific settings dump must contain architecture specific settings
""" """
section = configuration.section_name("build", "x86_64") section = configuration.section_name("build", "x86_64")
configuration.add_section(section) configuration.set_option(section, "archbuild_flags", "hello flag")
configuration.set(section, "archbuild_flags", "hello flag")
configuration.merge_sections("x86_64") configuration.merge_sections("x86_64")
dump = configuration.dump() dump = configuration.dump()
@ -76,7 +75,7 @@ def test_getlist(configuration: Configuration) -> None:
""" """
must return list of string correctly 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"] 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 must return list of string correctly for non-existing option
""" """
assert configuration.getlist("build", "test_list") == [] assert configuration.getlist("build", "test_list") == []
configuration.set("build", "test_list", "") configuration.set_option("build", "test_list", "")
assert configuration.getlist("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 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"] 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 must not fail if not include directory found
""" """
configuration.set("settings", "include", "path") configuration.set_option("settings", "include", "path")
configuration.load_includes() configuration.load_includes()
@ -144,8 +143,23 @@ def test_merge_sections_missing(configuration: Configuration) -> None:
""" """
section = configuration.section_name("build", "x86_64") section = configuration.section_name("build", "x86_64")
configuration.remove_section("build") configuration.remove_section("build")
configuration.add_section(section) configuration.set_option(section, "key", "value")
configuration.set(section, "key", "value")
configuration.merge_sections("x86_64") configuration.merge_sections("x86_64")
assert configuration.get("build", "key") == "value" 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"

View File

@ -61,6 +61,8 @@ def test_get_local_files(s3: S3, resource_path_root: Path) -> None:
Path("models/package_yay_srcinfo"), Path("models/package_yay_srcinfo"),
Path("web/templates/search-line.jinja2"), Path("web/templates/search-line.jinja2"),
Path("web/templates/build-status.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/repo-index.jinja2"),
Path("web/templates/sorttable.jinja2"), Path("web/templates/sorttable.jinja2"),
Path("web/templates/style.jinja2"), Path("web/templates/style.jinja2"),

View File

@ -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

View File

@ -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)

View File

View File

@ -3,7 +3,10 @@ import pytest
from aiohttp import web from aiohttp import web
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
import ahriman.core.auth.helpers
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.models.user import User
from ahriman.web.web import setup_service from ahriman.web.web import setup_service
@ -15,5 +18,26 @@ def application(configuration: Configuration, mocker: MockerFixture) -> web.Appl
:param mocker: mocker object :param mocker: mocker object
:return: application test instance :return: application test instance
""" """
mocker.patch.object(ahriman.core.auth.helpers, "_has_aiohttp_security", False)
mocker.patch("pathlib.Path.mkdir") mocker.patch("pathlib.Path.mkdir")
return setup_service("x86_64", configuration) 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

View File

@ -2,8 +2,12 @@ import pytest
from collections import namedtuple 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 @pytest.fixture
@ -12,4 +16,17 @@ def aiohttp_request() -> _request:
fixture for aiohttp like object fixture for aiohttp like object
:return: aiohttp like request test instance :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

View File

@ -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()

View File

@ -35,9 +35,22 @@ def test_run(application: web.Application, mocker: MockerFixture) -> None:
must run application must run application
""" """
port = 8080 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_application_mock = mocker.patch("aiohttp.web.run_app")
run_server(application) run_server(application)
run_application_mock.assert_called_with(application, host="127.0.0.1", port=port, run_application_mock.assert_called_with(application, host="127.0.0.1", port=port,
handle_signals=False, access_log=pytest.helpers.anyvar(int)) 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))

View File

@ -20,3 +20,18 @@ def client(application: web.Application, loop: BaseEventLoop,
""" """
mocker.patch("pathlib.Path.iterdir", return_value=[]) mocker.patch("pathlib.Path.iterdir", return_value=[])
return loop.run_until_complete(aiohttp_client(application)) 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))

View File

@ -1,19 +1,28 @@
from pytest_aiohttp import TestClient 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 (/) 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("/") response = await client.get("/")
assert response.status == 200 assert response.status == 200
assert await response.text() 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()

View File

@ -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()

View File

@ -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

View File

@ -8,6 +8,9 @@ database = /var/lib/pacman
repositories = core extra community multilib repositories = core extra community multilib
root = / root = /
[auth]
salt = salt
[build] [build]
archbuild_flags = archbuild_flags =
build_command = extra-x86_64-build build_command = extra-x86_64-build