* make auth method asyncs

* oauth2 demo support

* full coverage

* update docs
This commit is contained in:
Evgenii Alekseev 2021-09-12 21:41:38 +03:00 committed by GitHub
parent 14e8eee986
commit 168b2f6880
39 changed files with 695 additions and 251 deletions

View File

@ -107,6 +107,32 @@ For every external command run (which is actually not recommended if possible) c
Some packages provide different behaviour depending on configuration settings. In this cases inheritance is used and recommended way to deal with them is to call class method `load` from base classes. Some packages provide different behaviour depending on configuration settings. In this cases inheritance is used and recommended way to deal with them is to call class method `load` from base classes.
## Authorization
The package provides several authorization methods: disabled, based on configuration and OAuth2.
Disabled (default) authorization provider just allows everything for everyone and does not have any specific configuration (it uses some default configuration parameters though). It also provides generic interface for derived classes.
Mapping (aka configuration) provider uses hashed passwords with salt from configuration file in order to authenticate users. This provider also enables user permission checking (read/write) (authorization). Thus, it defines the following methods:
* `check_credentials` - user password validation (authentication).
* `verify_access` - user permission validation (authorization).
Passwords must be stored in configuration as `hash(password + salt)`, where `password` is user defined password (taken from user input), `salt` is random string (any length) defined globally in configuration and `hash` is secure hash function. Thus, the following configuration
```ini
[auth:read]
username = $6$rounds=656000$mWBiecMPrHAL1VgX$oU4Y5HH8HzlvMaxwkNEJjK13ozElyU1wAHBoO/WW5dAaE4YEfnB0X3FxbynKMl4FBdC3Ovap0jINz4LPkNADg0
```
means that there is user `username` with `read` access and password `password` hashed by `sha512` with salt `salt`.
OAuth provider uses library definitions (`aioauth-client`) in order _authenticate_ users. It still requires user permission to be set in configuration, thus it inherits mapping provider without any changes. Whereas we could override `check_credentials` (authentication method) by something custom, OAuth flow is a bit more complex than just forward request, thus we have to implement the flow in login form.
OAuth's implementation also allows authenticating users via username + password (in the same way as mapping does) though it is not recommended for end-users and password must be left blank. In particular this feature is used by service reporting (aka robots).
In order to configure users there is special command.
## Additional features ## Additional features
Some features require optional dependencies to be installed: Some features require optional dependencies to be installed:
@ -122,7 +148,8 @@ Some features require optional dependencies to be installed:
Web application requires the following python packages to be installed: Web application requires the following python packages to be installed:
* Core part requires `aiohttp` (application itself), `aiohttp_jinja2` and `Jinja2` (HTML generation from templates). * Core part requires `aiohttp` (application itself), `aiohttp_jinja2` and `Jinja2` (HTML generation from templates).
* In addition authorization feature requires `aiohttp_security`, `aiohttp_session` and `cryptography`. * In addition, authorization feature requires `aiohttp_security`, `aiohttp_session` and `cryptography`.
* In addition to base authorization dependencies, OAuth2 also requires `aioauth-client` library.
## Middlewares ## Middlewares
@ -150,7 +177,7 @@ Service provides optional authorization which can be turned on in settings. In o
If this feature is configured any request except for whitelisted will be prohibited without authentication. In addition, configuration flag `auth.allow_read_only` can be used in order to allow seeing main page without authorization (this page is in default white list). If this feature is configured any request except for whitelisted will be prohibited without authentication. In addition, configuration flag `auth.allow_read_only` can be used in order to allow seeing main page without authorization (this page is in default white list).
For authorized users it uses encrypted session cookies to store tokens; encryption key is generated each time at the start of the application. For authenticated users it uses encrypted session cookies to store tokens; encryption key is generated each time at the start of the application.
## External calls ## External calls

View File

@ -20,13 +20,17 @@ libalpm and AUR related configuration.
## `auth` group ## `auth` group
Base authorization settings. Base authorization settings. `OAuth` provider requires `aioauth-client` library to be installed.
* `target` - specifies authorization provider, string, optional, default `disabled`. Allowed values are `disabled`, `configuration`. * `target` - specifies authorization provider, string, optional, default `disabled`. Allowed values are `disabled`, `configuration`, `oauth`.
* `allow_read_only` - allow requesting read only pages without authorization, boolean, required. * `allow_read_only` - allow requesting read only pages without authorization, boolean, required.
* `allowed_paths` - URI paths (exact match) which can be accessed without authorization, space separated list of strings, optional. * `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. * `allowed_paths_groups` - URI paths prefixes which can be accessed without authorization, space separated list of strings, optional.
* `client_id` - OAuth2 application client ID, string, required in case if `oauth` is used.
* `client_secret` - OAuth2 application client secret key, string, required in case if `oauth` is used.
* `max_age` - parameter which controls both cookie expiration and token expiration inside the service, integer, optional, default is 7 days. * `max_age` - parameter which controls both cookie expiration and token expiration inside the service, integer, optional, default is 7 days.
* `oauth_provider` - OAuth2 provider class name as is in `aioauth-client` (e.g. `GoogleClient`, `GithubClient` etc), string, required in case if `oauth` is used.
* `oauth_scopes` - scopes list for OAuth2 provider, which will allow retrieving user email (which is used for checking user permissions), e.g. `https://www.googleapis.com/auth/userinfo.email` for `GoogleClient` or `user:email` for `GithubClient`, space separated list of strings, required in case if `oauth` is used.
* `salt` - password hash salt, string, required in case if authorization enabled (automatically generated by `create-user` subcommand). * `salt` - password hash salt, string, required in case if authorization enabled (automatically generated by `create-user` subcommand).
## `auth:*` groups ## `auth:*` groups
@ -35,7 +39,8 @@ Authorization mapping. Group name must refer to user access level, i.e. it shoul
Key is always username (case-insensitive), option value depends on authorization provider: 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. * `OAuth` - by default requires only usernames and ignores values. But in case of direct login method call (via POST request) it will act as `Mapping` authorization method.
* `Mapping` (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
@ -121,7 +126,7 @@ 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. * `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. This option is required in case if `OAuth` provider is used.
* `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. * `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.

View File

@ -13,6 +13,7 @@ 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-aioauth-client: web server with OAuth2 authorization'
'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-security: web server with authorization'

View File

@ -12,6 +12,8 @@ root = /
target = disabled target = disabled
allow_read_only = yes allow_read_only = yes
max_age = 604800 max_age = 604800
oauth_provider = GoogleClient
oauth_scopes = https://www.googleapis.com/auth/userinfo.email
[build] [build]
archbuild_flags = archbuild_flags =

View File

@ -14,7 +14,7 @@
<div class="container"> <div class="container">
<h1>ahriman <h1>ahriman
{% if authorized %} {% if auth.authenticated %}
<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/repository-{{ repository }}-informational" alt="{{ repository }}"> <img src="https://img.shields.io/badge/repository-{{ repository }}-informational" alt="{{ repository }}">
<img src="https://img.shields.io/badge/architecture-{{ architecture }}-informational" alt="{{ architecture }}"> <img src="https://img.shields.io/badge/architecture-{{ architecture }}-informational" alt="{{ architecture }}">
@ -25,7 +25,7 @@
<div class="container"> <div class="container">
<div id="toolbar"> <div id="toolbar">
{% if not auth_enabled or auth_username is not none %} {% if not auth.enabled or auth.username is not none %}
<button id="add" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addForm"> <button id="add" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addForm">
<i class="fa fa-plus"></i> Add <i class="fa fa-plus"></i> Add
</button> </button>
@ -70,7 +70,7 @@
</thead> </thead>
<tbody> <tbody>
{% if authorized %} {% if auth.authenticated %}
{% for package in packages %} {% for package in packages %}
<tr data-package-base="{{ package.base }}"> <tr data-package-base="{{ package.base }}">
<td data-checkbox="true"></td> <td data-checkbox="true"></td>
@ -85,7 +85,7 @@
{% endfor %} {% endfor %}
{% else %} {% else %}
<tr> <tr>
<td colspan="100%">In order to see statuses you must login first</td> <td colspan="100%">In order to see statuses you must login first.</td>
</tr> </tr>
{% endif %} {% endif %}
</tbody> </tbody>
@ -100,19 +100,19 @@
<li><a class="nav-link" href="https://github.com/arcan1s/ahriman/issues" title="issues tracker">report a bug</a></li> <li><a class="nav-link" href="https://github.com/arcan1s/ahriman/issues" title="issues tracker">report a bug</a></li>
</ul> </ul>
{% if auth_enabled %} {% if auth.enabled %}
{% if auth_username is none %} {% if auth.username is none %}
<button type="button" class="btn btn-link" data-bs-toggle="modal" data-bs-target="#loginForm" style="text-decoration: none">login</button> {{ auth.control|safe }}
{% else %} {% else %}
<form action="/user-api/v1/logout" method="post"> <form action="/user-api/v1/logout" method="post">
<button class="btn btn-link" style="text-decoration: none">logout ({{ auth_username }})</button> <button class="btn btn-link" style="text-decoration: none">logout ({{ auth.username }})</button>
</form> </form>
{% endif %} {% endif %}
{% endif %} {% endif %}
</footer> </footer>
</div> </div>
{% if auth_enabled %} {% if auth.enabled %}
{% include "build-status/login-modal.jinja2" %} {% include "build-status/login-modal.jinja2" %}
{% endif %} {% endif %}

View File

@ -106,6 +106,7 @@ setup(
"Jinja2", "Jinja2",
"aiohttp", "aiohttp",
"aiohttp_jinja2", "aiohttp_jinja2",
"aioauth-client",
"aiohttp_session", "aiohttp_session",
"aiohttp_security", "aiohttp_security",
"cryptography", "cryptography",

View File

@ -19,10 +19,14 @@
# #
from __future__ import annotations from __future__ import annotations
from typing import Optional, Type import logging
from typing import Dict, Optional, Type
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import DuplicateUser
from ahriman.models.auth_settings import AuthSettings from ahriman.models.auth_settings import AuthSettings
from ahriman.models.user import User
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@ -45,6 +49,8 @@ class Auth:
:param configuration: configuration instance :param configuration: configuration instance
:param provider: authorization type definition :param provider: authorization type definition
""" """
self.logger = logging.getLogger("http")
self.allow_read_only = configuration.getboolean("auth", "allow_read_only") self.allow_read_only = configuration.getboolean("auth", "allow_read_only")
self.allowed_paths = set(configuration.getlist("auth", "allowed_paths")) self.allowed_paths = set(configuration.getlist("auth", "allowed_paths"))
self.allowed_paths.update(self.ALLOWED_PATHS) self.allowed_paths.update(self.ALLOWED_PATHS)
@ -53,6 +59,17 @@ class Auth:
self.enabled = provider.is_enabled self.enabled = provider.is_enabled
self.max_age = configuration.getint("auth", "max_age", fallback=7 * 24 * 3600) self.max_age = configuration.getint("auth", "max_age", fallback=7 * 24 * 3600)
@property
def auth_control(self) -> str:
"""
This workaround is required to make different behaviour for login interface.
In case of internal authentication it must provide an interface (modal form) to login with button sends POST
request. But for an external providers behaviour can be different: e.g. OAuth provider requires sending GET
request to external resource
:return: login control as html code to insert
"""
return """<button type="button" class="btn btn-link" data-bs-toggle="modal" data-bs-target="#loginForm" style="text-decoration: none">login</button>"""
@classmethod @classmethod
def load(cls: Type[Auth], configuration: Configuration) -> Auth: def load(cls: Type[Auth], configuration: Configuration) -> Auth:
""" """
@ -62,11 +79,33 @@ class Auth:
""" """
provider = AuthSettings.from_option(configuration.get("auth", "target", fallback="disabled")) provider = AuthSettings.from_option(configuration.get("auth", "target", fallback="disabled"))
if provider == AuthSettings.Configuration: if provider == AuthSettings.Configuration:
from ahriman.core.auth.mapping_auth import MappingAuth from ahriman.core.auth.mapping import Mapping
return MappingAuth(configuration) return Mapping(configuration)
if provider == AuthSettings.OAuth:
from ahriman.core.auth.oauth import OAuth
return OAuth(configuration)
return cls(configuration) return cls(configuration)
def check_credentials(self, username: Optional[str], password: Optional[str]) -> bool: # pylint: disable=no-self-use @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
async def check_credentials(self, username: Optional[str], password: Optional[str]) -> bool: # pylint: disable=no-self-use
""" """
validate user password validate user password
:param username: username :param username: username
@ -76,20 +115,20 @@ class Auth:
del username, password del username, password
return True return True
def is_safe_request(self, uri: Optional[str], required: UserAccess) -> bool: async def is_safe_request(self, uri: Optional[str], required: UserAccess) -> bool:
""" """
check if requested path are allowed without authorization check if requested path are allowed without authorization
:param uri: request uri :param uri: request uri
:param required: required access level :param required: required access level
:return: True in case if this URI can be requested without authorization and False otherwise :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
if required == UserAccess.Read and self.allow_read_only: if required == UserAccess.Read and self.allow_read_only:
return True # in case if read right requested and allowed in options return True # in case if read right requested and allowed in options
if not uri:
return False # request without context is not allowed
return uri in self.allowed_paths or any(uri.startswith(path) for path in self.allowed_paths_groups) return uri in self.allowed_paths or any(uri.startswith(path) for path in self.allowed_paths_groups)
def known_username(self, username: str) -> bool: # pylint: disable=no-self-use async def known_username(self, username: Optional[str]) -> bool: # pylint: disable=no-self-use
""" """
check if user is known check if user is known
:param username: username :param username: username
@ -98,7 +137,7 @@ class Auth:
del username del username
return True return True
def verify_access(self, username: str, required: UserAccess, context: Optional[str]) -> bool: # pylint: disable=no-self-use async 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 validate if user has access to requested resource
:param username: username :param username: username

View File

@ -17,17 +17,16 @@
# 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 typing import Dict, Optional from typing import Optional
from ahriman.core.auth.auth import Auth from ahriman.core.auth.auth import Auth
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import DuplicateUser
from ahriman.models.auth_settings import AuthSettings from ahriman.models.auth_settings import AuthSettings
from ahriman.models.user import User from ahriman.models.user import User
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
class MappingAuth(Auth): class Mapping(Auth):
""" """
user authorization based on mapping from configuration file user authorization based on mapping from configuration file
:ivar salt: random generated string to salt passwords :ivar salt: random generated string to salt passwords
@ -44,26 +43,7 @@ class MappingAuth(Auth):
self.salt = configuration.get("auth", "salt") self.salt = configuration.get("auth", "salt")
self._users = self.get_users(configuration) self._users = self.get_users(configuration)
@staticmethod async def check_credentials(self, username: Optional[str], password: Optional[str]) -> bool:
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 validate user password
:param username: username :param username: username
@ -84,15 +64,15 @@ class MappingAuth(Auth):
normalized_user = username.lower() normalized_user = username.lower()
return self._users.get(normalized_user) return self._users.get(normalized_user)
def known_username(self, username: str) -> bool: async def known_username(self, username: Optional[str]) -> bool:
""" """
check if user is known check if user is known
:param username: username :param username: username
:return: True in case if user is known and can be authorized and False otherwise :return: True in case if user is known and can be authorized and False otherwise
""" """
return self.get_user(username) is not None return username is not None and self.get_user(username) is not None
def verify_access(self, username: str, required: UserAccess, context: Optional[str]) -> bool: async def verify_access(self, username: str, required: UserAccess, context: Optional[str]) -> bool:
""" """
validate if user has access to requested resource validate if user has access to requested resource
:param username: username :param username: username
@ -100,6 +80,5 @@ class MappingAuth(Auth):
:param context: URI request path :param context: URI request path
:return: True in case if user is allowed to do this request and False otherwise :return: True in case if user is allowed to do this request and False otherwise
""" """
del context
user = self.get_user(username) user = self.get_user(username)
return user is not None and user.verify_access(required) return user is not None and user.verify_access(required)

View File

@ -0,0 +1,113 @@
#
# 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 aioauth_client # type: ignore
from typing import Optional, Type
from ahriman.core.auth.mapping import Mapping
from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import InvalidOption
from ahriman.models.auth_settings import AuthSettings
class OAuth(Mapping):
"""
OAuth user authorization.
It is required to create application first and put application credentials.
:ivar client_id: application client id
:ivar client_secret: application client secret key
:ivar provider: provider class, should be one of aiohttp-client provided classes
:ivar redirect_uri: redirect URI registered in provider
:ivar scopes: list of scopes required by the application
"""
def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.OAuth) -> None:
"""
default constructor
:param configuration: configuration instance
:param provider: authorization type definition
"""
Mapping.__init__(self, configuration, provider)
self.client_id = configuration.get("auth", "client_id")
self.client_secret = configuration.get("auth", "client_secret")
# in order to use OAuth feature the service must be publicity available
# thus we expect that address is set
self.redirect_uri = f"""{configuration.get("web", "address")}/user-api/v1/login"""
self.provider = self.get_provider(configuration.get("auth", "oauth_provider"))
# it is list but we will have to convert to string it anyway
self.scopes = configuration.get("auth", "oauth_scopes")
@property
def auth_control(self) -> str:
"""
:return: login control as html code to insert
"""
return """<a class="nav-link" href="/user-api/v1/login" title="login via OAuth2">login</a>"""
@staticmethod
def get_provider(name: str) -> Type[aioauth_client.OAuth2Client]:
"""
load OAuth2 provider by name
:param name: name of the provider. Must be valid class defined in aioauth-client library
:return: loaded provider type
"""
provider: Type[aioauth_client.OAuth2Client] = getattr(aioauth_client, name)
try:
is_oauth2_client = issubclass(provider, aioauth_client.OAuth2Client)
except TypeError: # what if it is random string?
is_oauth2_client = False
if not is_oauth2_client:
raise InvalidOption(name)
return provider
def get_client(self) -> aioauth_client.OAuth2Client:
"""
load client from parameters
:return: generated client according to current settings
"""
return self.provider(client_id=self.client_id, client_secret=self.client_secret)
def get_oauth_url(self) -> str:
"""
get authorization URI for the specified settings
:return: authorization URI as a string
"""
client = self.get_client()
uri: str = client.get_authorize_url(scope=self.scopes, redirect_uri=self.redirect_uri)
return uri
async def get_oauth_username(self, code: str) -> Optional[str]:
"""
extract OAuth username from remote
:param code: authorization code provided by external service
:return: username as is in OAuth provider
"""
try:
client = self.get_client()
access_token, _ = await client.get_access_token(code, redirect_uri=self.redirect_uri)
client.access_token = access_token
print(f"HEEELOOOO {client}")
user, _ = await client.user_info()
username: str = user.email
return username
except Exception:
self.logger.exception("got exception while performing request")
return None

View File

@ -63,11 +63,12 @@ class InitializeException(Exception):
base service initialization exception base service initialization exception
""" """
def __init__(self) -> None: def __init__(self, details: str) -> None:
""" """
default constructor default constructor
:param details: details of the exception
""" """
Exception.__init__(self, "Could not load service") Exception.__init__(self, f"Could not load service: {details}")
class InvalidOption(Exception): class InvalidOption(Exception):

View File

@ -46,12 +46,12 @@ def check_output(*args: str, exception: Optional[Exception], cwd: Optional[Path]
if logger is not None: if logger is not None:
for line in result.splitlines(): for line in result.splitlines():
logger.debug(line) logger.debug(line)
return result
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
if e.output is not None and logger is not None: if e.output is not None and logger is not None:
for line in e.output.splitlines(): for line in e.output.splitlines():
logger.debug(line) logger.debug(line)
raise exception or e raise exception or e
return result
def exception_response_text(exception: requests.exceptions.HTTPError) -> str: def exception_response_text(exception: requests.exceptions.HTTPError) -> str:

View File

@ -30,10 +30,12 @@ class AuthSettings(Enum):
web authorization type web authorization type
:cvar Disabled: authorization is disabled :cvar Disabled: authorization is disabled
:cvar Configuration: configuration based authorization :cvar Configuration: configuration based authorization
:cvar OAuth: OAuth based provider
""" """
Disabled = auto() Disabled = auto()
Configuration = auto() Configuration = auto()
OAuth = auto()
@classmethod @classmethod
def from_option(cls: Type[AuthSettings], value: str) -> AuthSettings: def from_option(cls: Type[AuthSettings], value: str) -> AuthSettings:
@ -46,6 +48,8 @@ class AuthSettings(Enum):
return cls.Disabled return cls.Disabled
if value.lower() in ("configuration", "mapping"): if value.lower() in ("configuration", "mapping"):
return cls.Configuration return cls.Configuration
if value.lower() in ('oauth', 'oauth2'):
return cls.OAuth
raise InvalidOption(value) raise InvalidOption(value)
@property @property

View File

@ -37,6 +37,7 @@ class Counters:
:ivar failed: packages in failed status count :ivar failed: packages in failed status count
:ivar success: packages in success status count :ivar success: packages in success status count
""" """
total: int total: int
unknown: int = 0 unknown: int = 0
pending: int = 0 pending: int = 0

View File

@ -34,6 +34,7 @@ class InternalStatus:
:ivar repository: repository name :ivar repository: repository name
:ivar version: service version :ivar version: service version
""" """
architecture: Optional[str] = None architecture: Optional[str] = None
packages: Counters = field(default=Counters(total=0)) packages: Counters = field(default=Counters(total=0))
repository: Optional[str] = None repository: Optional[str] = None

View File

@ -35,6 +35,7 @@ class User:
:ivar password: hashed user password with salt :ivar password: hashed user password with salt
:ivar access: user role :ivar access: user role
""" """
username: str username: str
password: str password: str
access: UserAccess access: UserAccess
@ -42,16 +43,18 @@ class User:
_HASHER = sha512_crypt _HASHER = sha512_crypt
@classmethod @classmethod
def from_option(cls: Type[User], username: Optional[str], password: Optional[str]) -> Optional[User]: def from_option(cls: Type[User], username: Optional[str], password: Optional[str],
access: UserAccess = UserAccess.Read) -> Optional[User]:
""" """
build user descriptor from configuration options build user descriptor from configuration options
:param username: username :param username: username
:param password: password as string :param password: password as string
:param access: optional user access
:return: generated user descriptor if all options are supplied and None otherwise :return: generated user descriptor if all options are supplied and None otherwise
""" """
if username is None or password is None: if username is None or password is None:
return None return None
return cls(username, password, UserAccess.Read) return cls(username, password, access)
@staticmethod @staticmethod
def generate_password(length: int) -> str: def generate_password(length: int) -> str:
@ -70,7 +73,10 @@ class User:
:param salt: salt for hashed password :param salt: salt for hashed password
:return: True in case if password matches, False otherwise :return: True in case if password matches, False otherwise
""" """
verified: bool = self._HASHER.verify(password + salt, self.password) try:
verified: bool = self._HASHER.verify(password + salt, self.password)
except ValueError:
verified = False # the absence of evidence is not the evidence of absence (c) Gin Rummy
return verified return verified
def hash_password(self, salt: str) -> str: def hash_password(self, salt: str) -> str:
@ -79,6 +85,10 @@ class User:
:param salt: salt for hashed password :param salt: salt for hashed password
:return: hashed string to store in configuration :return: hashed string to store in configuration
""" """
if not self.password:
# in case of empty password we leave it empty. This feature is used by any external (like OAuth) provider
# when we do not store any password here
return ""
password_hash: str = self._HASHER.hash(self.password + salt) password_hash: str = self._HASHER.hash(self.password + salt)
return password_hash return password_hash

View File

@ -48,11 +48,11 @@ class AuthorizationPolicy(aiohttp_security.AbstractAuthorizationPolicy): # type
async def authorized_userid(self, identity: str) -> Optional[str]: async def authorized_userid(self, identity: str) -> Optional[str]:
""" """
retrieve authorized username retrieve authenticated username
:param identity: username :param identity: username
:return: user identity (username) in case if user exists and None otherwise :return: user identity (username) in case if user exists and None otherwise
""" """
return identity if self.validator.known_username(identity) else None return identity if await self.validator.known_username(identity) else None
async def permits(self, identity: str, permission: UserAccess, context: Optional[str] = None) -> bool: async def permits(self, identity: str, permission: UserAccess, context: Optional[str] = None) -> bool:
""" """
@ -62,7 +62,7 @@ class AuthorizationPolicy(aiohttp_security.AbstractAuthorizationPolicy): # type
:param context: URI request path :param context: URI request path
:return: True in case if user is allowed to perform this request and False otherwise :return: True in case if user is allowed to perform this request and False otherwise
""" """
return self.validator.verify_access(identity, permission, context) return await self.validator.verify_access(identity, permission, context)
def auth_handler(validator: Auth) -> MiddlewareType: def auth_handler(validator: Auth) -> MiddlewareType:
@ -78,7 +78,7 @@ def auth_handler(validator: Auth) -> MiddlewareType:
else: else:
permission = UserAccess.Write permission = UserAccess.Write
if not validator.is_safe_request(request.path, permission): if not await validator.is_safe_request(request.path, permission):
await aiohttp_security.check_permission(request, permission, request.path) await aiohttp_security.check_permission(request, permission, request.path)
return await handler(request) return await handler(request)

View File

@ -61,6 +61,7 @@ def setup_routes(application: Application, static_path: Path) -> None:
GET /status-api/v1/status get web service status itself GET /status-api/v1/status get web service status itself
GET /user-api/v1/login OAuth2 handler for login
POST /user-api/v1/login login to service POST /user-api/v1/login login to service
POST /user-api/v1/logout logout from service POST /user-api/v1/logout logout from service
@ -92,5 +93,6 @@ def setup_routes(application: Application, static_path: Path) -> None:
application.router.add_get("/status-api/v1/status", StatusView, allow_head=True) application.router.add_get("/status-api/v1/status", StatusView, allow_head=True)
application.router.add_get("/user-api/v1/login", LoginView)
application.router.add_post("/user-api/v1/login", LoginView) application.router.add_post("/user-api/v1/login", LoginView)
application.router.add_post("/user-api/v1/logout", LogoutView) application.router.add_post("/user-api/v1/logout", LogoutView)

View File

@ -34,9 +34,11 @@ 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 - authorization descriptor, required
auth_enabled - whether authorization is enabled by configuration or not, boolean, required * authenticated - alias to check if user can see the page, boolean, required
auth_username - authorized user id if any, string. None means not authorized * control - HTML to insert for login control, HTML string, required
* enabled - whether authorization is enabled by configuration or not, boolean, required
* username - authenticated username if any, string, null means not authenticated
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
@ -74,24 +76,27 @@ class IndexView(BaseView):
"status_color": status.status.bootstrap_color(), "status_color": status.status.bootstrap_color(),
"timestamp": pretty_datetime(status.timestamp), "timestamp": pretty_datetime(status.timestamp),
"version": package.version, "version": package.version,
"web_url": package.web_url "web_url": package.web_url,
} for package, status in sorted(self.service.packages, key=lambda item: item[0].base) } for package, status in sorted(self.service.packages, key=lambda item: item[0].base)
] ]
service = { service = {
"status": self.service.status.status.value, "status": self.service.status.status.value,
"status_color": self.service.status.status.badges_color(), "status_color": self.service.status.status.badges_color(),
"timestamp": pretty_datetime(self.service.status.timestamp) "timestamp": pretty_datetime(self.service.status.timestamp),
} }
# auth block # auth block
auth_username = await authorized_userid(self.request) auth_username = await authorized_userid(self.request)
authorized = not self.validator.enabled or self.validator.allow_read_only or auth_username is not None auth = {
"authenticated": not self.validator.enabled or self.validator.allow_read_only or auth_username is not None,
"control": self.validator.auth_control,
"enabled": self.validator.enabled,
"username": auth_username,
}
return { return {
"architecture": self.service.architecture, "architecture": self.service.architecture,
"authorized": authorized, "auth": auth,
"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

@ -17,7 +17,7 @@
# 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 HTTPFound, HTTPUnauthorized, Response from aiohttp.web import HTTPFound, HTTPMethodNotAllowed, HTTPUnauthorized, Response
from ahriman.core.auth.helpers import remember from ahriman.core.auth.helpers import remember
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
@ -28,6 +28,33 @@ class LoginView(BaseView):
login endpoint view login endpoint view
""" """
async def get(self) -> Response:
"""
OAuth2 response handler
In case if code provided it will do a request to get user email. In case if no code provided it will redirect
to authorization url provided by OAuth client
:return: redirect to main page
"""
from ahriman.core.auth.oauth import OAuth
code = self.request.query.getone("code", default=None)
oauth_provider = self.validator
if not isinstance(oauth_provider, OAuth): # there is actually property, but mypy does not like it anyway
raise HTTPMethodNotAllowed(self.request.method, ["POST"])
if not code:
return HTTPFound(oauth_provider.get_oauth_url())
response = HTTPFound("/")
username = await oauth_provider.get_oauth_username(code)
if await self.validator.known_username(username):
await remember(self.request, response, username)
return response
raise HTTPUnauthorized()
async def post(self) -> Response: async def post(self) -> Response:
""" """
login user to service login user to service
@ -44,7 +71,7 @@ class LoginView(BaseView):
username = data.get("username") username = data.get("username")
response = HTTPFound("/") response = HTTPFound("/")
if self.validator.check_credentials(username, data.get("password")): if await self.validator.check_credentials(username, data.get("password")):
await remember(self.request, response, username) await remember(self.request, response, username)
return response return response

View File

@ -49,8 +49,9 @@ async def on_startup(application: web.Application) -> None:
try: try:
application["watcher"].load() application["watcher"].load()
except Exception: except Exception:
application.logger.exception("could not load packages") message = "could not load packages"
raise InitializeException() application.logger.exception(message)
raise InitializeException(message)
def run_server(application: web.Application) -> None: def run_server(application: web.Application) -> None:

View File

@ -1,13 +1,26 @@
import pytest import pytest
from ahriman.core.auth.mapping_auth import MappingAuth from ahriman.core.auth.mapping import Mapping
from ahriman.core.auth.oauth import OAuth
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
@pytest.fixture @pytest.fixture
def mapping_auth(configuration: Configuration) -> MappingAuth: def mapping(configuration: Configuration) -> Mapping:
""" """
auth provider fixture auth provider fixture
:param configuration: configuration fixture
:return: auth service instance :return: auth service instance
""" """
return MappingAuth(configuration) return Mapping(configuration)
@pytest.fixture
def oauth(configuration: Configuration) -> OAuth:
"""
OAuth provider fixture
:param configuration: configuration fixture
:return: OAuth2 service instance
"""
configuration.set("web", "address", "https://example.com")
return OAuth(configuration)

View File

@ -1,10 +1,22 @@
import pytest
from ahriman.core.auth.auth import Auth from ahriman.core.auth.auth import Auth
from ahriman.core.auth.mapping_auth import MappingAuth from ahriman.core.auth.mapping import Mapping
from ahriman.core.auth.oauth import OAuth
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import DuplicateUser
from ahriman.models.user import User from ahriman.models.user import User
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
def test_auth_control(auth: Auth) -> None:
"""
must return a control for authorization
"""
assert auth.auth_control
assert "button" in auth.auth_control # I think it should be button
def test_load_dummy(configuration: Configuration) -> None: def test_load_dummy(configuration: Configuration) -> None:
""" """
must load dummy validator if authorization is not enabled must load dummy validator if authorization is not enabled
@ -28,63 +40,119 @@ def test_load_mapping(configuration: Configuration) -> None:
""" """
configuration.set_option("auth", "target", "configuration") configuration.set_option("auth", "target", "configuration")
auth = Auth.load(configuration) auth = Auth.load(configuration)
assert isinstance(auth, MappingAuth) assert isinstance(auth, Mapping)
def test_check_credentials(auth: Auth, user: User) -> None: def test_load_oauth(configuration: Configuration) -> None:
"""
must load OAuth2 validator if option set
"""
configuration.set_option("auth", "target", "oauth")
configuration.set_option("web", "address", "https://example.com")
auth = Auth.load(configuration)
assert isinstance(auth, OAuth)
def test_get_users(mapping: Auth, configuration: Configuration) -> None:
"""
must return valid user list
"""
user_write = User("user_write", "pwd_write", UserAccess.Write)
write_section = Configuration.section_name("auth", user_write.access.value)
configuration.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.get_users(configuration)
expected = {user_write.username: user_write, user_read.username: user_read}
assert users == expected
def test_get_users_normalized(mapping: Auth, 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.get_users(configuration)
expected = user.username.lower()
assert expected in users
assert users[expected].username == expected
def test_get_users_duplicate(mapping: Auth, 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.get_users(configuration)
async def test_check_credentials(auth: Auth, user: User) -> None:
""" """
must pass any credentials must pass any credentials
""" """
assert auth.check_credentials(user.username, user.password) assert await auth.check_credentials(user.username, user.password)
assert auth.check_credentials(None, "") assert await auth.check_credentials(None, "")
assert auth.check_credentials("", None) assert await auth.check_credentials("", None)
assert auth.check_credentials(None, None) assert await auth.check_credentials(None, None)
def test_is_safe_request(auth: Auth) -> None: async def test_is_safe_request(auth: Auth) -> None:
""" """
must validate safe request must validate safe request
""" """
# login and logout are always safe # login and logout are always safe
assert auth.is_safe_request("/user-api/v1/login", UserAccess.Write) assert await auth.is_safe_request("/user-api/v1/login", UserAccess.Write)
assert auth.is_safe_request("/user-api/v1/logout", UserAccess.Write) assert await auth.is_safe_request("/user-api/v1/logout", UserAccess.Write)
auth.allowed_paths.add("/safe") auth.allowed_paths.add("/safe")
auth.allowed_paths_groups.add("/unsafe/safe") auth.allowed_paths_groups.add("/unsafe/safe")
assert auth.is_safe_request("/safe", UserAccess.Write) assert await auth.is_safe_request("/safe", UserAccess.Write)
assert not auth.is_safe_request("/unsafe", UserAccess.Write) assert not await auth.is_safe_request("/unsafe", UserAccess.Write)
assert auth.is_safe_request("/unsafe/safe", UserAccess.Write) assert await auth.is_safe_request("/unsafe/safe", UserAccess.Write)
assert auth.is_safe_request("/unsafe/safe/suffix", UserAccess.Write) assert await auth.is_safe_request("/unsafe/safe/suffix", UserAccess.Write)
def test_is_safe_request_empty(auth: Auth) -> None: async def test_is_safe_request_empty(auth: Auth) -> None:
""" """
must not allow requests without path must not allow requests without path
""" """
assert not auth.is_safe_request(None, UserAccess.Read) assert not await auth.is_safe_request(None, UserAccess.Read)
assert not auth.is_safe_request("", UserAccess.Read) assert not await auth.is_safe_request("", UserAccess.Read)
def test_is_safe_request_read_only(auth: Auth) -> None: async def test_is_safe_request_read_only(auth: Auth) -> None:
""" """
must allow read-only requests if it is set in settings must allow read-only requests if it is set in settings
""" """
assert auth.is_safe_request("/", UserAccess.Read) assert await auth.is_safe_request("/", UserAccess.Read)
auth.allow_read_only = True auth.allow_read_only = True
assert auth.is_safe_request("/unsafe", UserAccess.Read) assert await auth.is_safe_request("/unsafe", UserAccess.Read)
def test_known_username(auth: Auth, user: User) -> None: async def test_known_username(auth: Auth, user: User) -> None:
""" """
must allow any username must allow any username
""" """
assert auth.known_username(user.username) assert await auth.known_username(user.username)
def test_verify_access(auth: Auth, user: User) -> None: async def test_verify_access(auth: Auth, user: User) -> None:
""" """
must allow any access must allow any access
""" """
assert auth.verify_access(user.username, user.access, None) assert await auth.verify_access(user.username, user.access, None)
assert auth.verify_access(user.username, UserAccess.Write, None) assert await auth.verify_access(user.username, UserAccess.Write, None)

View File

@ -0,0 +1,73 @@
from ahriman.core.auth.mapping import Mapping
from ahriman.models.user import User
from ahriman.models.user_access import UserAccess
async def test_check_credentials(mapping: Mapping, user: User) -> None:
"""
must return true for valid credentials
"""
current_password = user.password
user.password = user.hash_password(mapping.salt)
mapping._users[user.username] = user
assert await mapping.check_credentials(user.username, current_password)
# here password is hashed so it is invalid
assert not await mapping.check_credentials(user.username, user.password)
async def test_check_credentials_empty(mapping: Mapping) -> None:
"""
must reject on empty credentials
"""
assert not await mapping.check_credentials(None, "")
assert not await mapping.check_credentials("", None)
assert not await mapping.check_credentials(None, None)
async def test_check_credentials_unknown(mapping: Mapping, user: User) -> None:
"""
must reject on unknown user
"""
assert not await mapping.check_credentials(user.username, user.password)
def test_get_user(mapping: Mapping, user: User) -> None:
"""
must return user from storage by username
"""
mapping._users[user.username] = user
assert mapping.get_user(user.username) == user
def test_get_user_normalized(mapping: Mapping, user: User) -> None:
"""
must return user from storage by username case-insensitive
"""
mapping._users[user.username] = user
assert mapping.get_user(user.username.upper()) == user
def test_get_user_unknown(mapping: Mapping, user: User) -> None:
"""
must return None in case if no user found
"""
assert mapping.get_user(user.username) is None
async def test_known_username(mapping: Mapping, user: User) -> None:
"""
must allow only known users
"""
mapping._users[user.username] = user
assert await mapping.known_username(user.username)
assert not await mapping.known_username(None)
assert not await mapping.known_username(user.password)
async def test_verify_access(mapping: Mapping, user: User) -> None:
"""
must verify user access
"""
mapping._users[user.username] = user
assert await mapping.verify_access(user.username, user.access, None)
assert not await mapping.verify_access(user.username, UserAccess.Write, None)

View File

@ -1,121 +0,0 @@
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(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

@ -0,0 +1,98 @@
import aioauth_client
import pytest
from pytest_mock import MockerFixture
from ahriman.core.auth.oauth import OAuth
from ahriman.core.exceptions import InvalidOption
def test_auth_control(oauth: OAuth) -> None:
"""
must return a control for authorization
"""
assert oauth.auth_control
assert "<a" in oauth.auth_control # I think it should be a link
def test_get_provider() -> None:
"""
must return valid provider type
"""
assert OAuth.get_provider("OAuth2Client") == aioauth_client.OAuth2Client
assert OAuth.get_provider("GoogleClient") == aioauth_client.GoogleClient
assert OAuth.get_provider("GoogleClient") == aioauth_client.GoogleClient
def test_get_provider_not_a_type() -> None:
"""
must raise an exception if attribute is not a type
"""
with pytest.raises(InvalidOption):
OAuth.get_provider("__version__")
def test_get_provider_invalid_type() -> None:
"""
must raise an exception if attribute is not an OAuth2 client
"""
with pytest.raises(InvalidOption):
OAuth.get_provider("User")
with pytest.raises(InvalidOption):
OAuth.get_provider("OAuth1Client")
def test_get_client(oauth: OAuth) -> None:
"""
must return valid OAuth2 client
"""
client = oauth.get_client()
assert isinstance(client, aioauth_client.GoogleClient)
assert client.client_id == oauth.client_id
assert client.client_secret == oauth.client_secret
def test_get_oauth_url(oauth: OAuth, mocker: MockerFixture) -> None:
"""
must generate valid OAuth authorization URL
"""
authorize_url_mock = mocker.patch("aioauth_client.GoogleClient.get_authorize_url")
oauth.get_oauth_url()
authorize_url_mock.assert_called_with(scope=oauth.scopes, redirect_uri=oauth.redirect_uri)
async def test_get_oauth_username(oauth: OAuth, mocker: MockerFixture) -> None:
"""
must return authorized user ID
"""
access_token_mock = mocker.patch("aioauth_client.GoogleClient.get_access_token", return_value=("token", ""))
user_info_mock = mocker.patch("aioauth_client.GoogleClient.user_info",
return_value=(aioauth_client.User(email="email"), ""))
email = await oauth.get_oauth_username("code")
access_token_mock.assert_called_with("code", redirect_uri=oauth.redirect_uri)
user_info_mock.assert_called_once()
assert email == "email"
async def test_get_oauth_username_exception_1(oauth: OAuth, mocker: MockerFixture) -> None:
"""
must return None in case of OAuth request error (get_access_token)
"""
mocker.patch("aioauth_client.GoogleClient.get_access_token", side_effect=Exception())
user_info_mock = mocker.patch("aioauth_client.GoogleClient.user_info")
email = await oauth.get_oauth_username("code")
assert email is None
user_info_mock.assert_not_called()
async def test_get_oauth_username_exception_2(oauth: OAuth, mocker: MockerFixture) -> None:
"""
must return None in case of OAuth request error (user_info)
"""
mocker.patch("aioauth_client.GoogleClient.get_access_token", return_value=("token", ""))
mocker.patch("aioauth_client.GoogleClient.user_info", side_effect=Exception())
email = await oauth.get_oauth_username("code")
assert email is None

View File

@ -21,6 +21,10 @@ def test_from_option_valid() -> None:
assert AuthSettings.from_option("no") == AuthSettings.Disabled assert AuthSettings.from_option("no") == AuthSettings.Disabled
assert AuthSettings.from_option("NO") == AuthSettings.Disabled assert AuthSettings.from_option("NO") == AuthSettings.Disabled
assert AuthSettings.from_option("oauth") == AuthSettings.OAuth
assert AuthSettings.from_option("OAuth") == AuthSettings.OAuth
assert AuthSettings.from_option("OAuth2") == AuthSettings.OAuth
assert AuthSettings.from_option("configuration") == AuthSettings.Configuration assert AuthSettings.from_option("configuration") == AuthSettings.Configuration
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

View File

@ -10,6 +10,7 @@ def test_from_option(user: User) -> None:
# default is read access # default is read access
user.access = UserAccess.Write user.access = UserAccess.Write
assert User.from_option(user.username, user.password) != user assert User.from_option(user.username, user.password) != user
assert User.from_option(user.username, user.password, user.access) == user
def test_from_option_empty() -> None: def test_from_option_empty() -> None:
@ -32,6 +33,26 @@ def test_check_credentials_hash_password(user: User) -> None:
assert not user.check_credentials(user.password, "salt") assert not user.check_credentials(user.password, "salt")
def test_check_credentials_empty_hash(user: User) -> None:
"""
must reject any authorization if the hash is invalid
"""
current_password = user.password
assert not user.check_credentials(current_password, "salt")
user.password = ""
assert not user.check_credentials(current_password, "salt")
def test_hash_password_empty_hash(user: User) -> None:
"""
must return empty string after hash in case if password not set
"""
user.password = ""
assert user.hash_password("salt") == ""
user.password = None
assert user.hash_password("salt") == ""
def test_generate_password() -> None: def test_generate_password() -> None:
""" """
must generate password with specified length must generate password with specified length

View File

@ -2,10 +2,9 @@ import pytest
from aiohttp import web from aiohttp import web
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock
from ahriman.core.auth.auth import Auth from ahriman.core.auth.auth import Auth
from ahriman.core.configuration import Configuration
from ahriman.models.user import User from ahriman.models.user import User
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.middlewares.auth_handler import auth_handler, AuthorizationPolicy, setup_auth from ahriman.web.middlewares.auth_handler import auth_handler, AuthorizationPolicy, setup_auth
@ -23,7 +22,7 @@ async def test_permits(authorization_policy: AuthorizationPolicy, user: User) ->
""" """
must call validator check must call validator check
""" """
authorization_policy.validator = MagicMock() authorization_policy.validator = AsyncMock()
authorization_policy.validator.verify_access.return_value = True authorization_policy.validator.verify_access.return_value = True
assert await authorization_policy.permits(user.username, user.access, "/endpoint") assert await authorization_policy.permits(user.username, user.access, "/endpoint")

View File

@ -9,7 +9,7 @@ async def test_post(client: TestClient, mocker: MockerFixture) -> None:
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add") add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add")
response = await client.post("/service-api/v1/add", json={"packages": ["ahriman"]}) response = await client.post("/service-api/v1/add", json={"packages": ["ahriman"]})
assert response.status == 200 assert response.ok
add_mock.assert_called_with(["ahriman"], True) add_mock.assert_called_with(["ahriman"], True)
@ -20,7 +20,7 @@ async def test_post_now(client: TestClient, mocker: MockerFixture) -> None:
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add") add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add")
response = await client.post("/service-api/v1/add", json={"packages": ["ahriman"], "build_now": False}) response = await client.post("/service-api/v1/add", json={"packages": ["ahriman"], "build_now": False})
assert response.status == 200 assert response.ok
add_mock.assert_called_with(["ahriman"], False) add_mock.assert_called_with(["ahriman"], False)
@ -42,5 +42,5 @@ async def test_post_update(client: TestClient, mocker: MockerFixture) -> None:
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add") add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add")
response = await client.post("/service-api/v1/update", json={"packages": ["ahriman"]}) response = await client.post("/service-api/v1/update", json={"packages": ["ahriman"]})
assert response.status == 200 assert response.ok
add_mock.assert_called_with(["ahriman"], True) add_mock.assert_called_with(["ahriman"], True)

View File

@ -9,7 +9,7 @@ async def test_post(client: TestClient, mocker: MockerFixture) -> None:
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_remove") add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_remove")
response = await client.post("/service-api/v1/remove", json={"packages": ["ahriman"]}) response = await client.post("/service-api/v1/remove", json={"packages": ["ahriman"]})
assert response.status == 200 assert response.ok
add_mock.assert_called_with(["ahriman"]) add_mock.assert_called_with(["ahriman"])

View File

@ -11,7 +11,7 @@ async def test_get(client: TestClient, aur_package_ahriman: aur.Package, mocker:
mocker.patch("aur.search", return_value=[aur_package_ahriman]) mocker.patch("aur.search", return_value=[aur_package_ahriman])
response = await client.get("/service-api/v1/search", params={"for": "ahriman"}) response = await client.get("/service-api/v1/search", params={"for": "ahriman"})
assert response.status == 200 assert response.ok
assert await response.json() == ["ahriman"] assert await response.json() == ["ahriman"]
@ -33,7 +33,7 @@ async def test_get_join(client: TestClient, mocker: MockerFixture) -> None:
search_mock = mocker.patch("aur.search") search_mock = mocker.patch("aur.search")
response = await client.get("/service-api/v1/search", params=[("for", "ahriman"), ("for", "maybe")]) response = await client.get("/service-api/v1/search", params=[("for", "ahriman"), ("for", "maybe")])
assert response.status == 200 assert response.ok
search_mock.assert_called_with("ahriman maybe") search_mock.assert_called_with("ahriman maybe")
@ -44,7 +44,7 @@ async def test_get_join_filter(client: TestClient, mocker: MockerFixture) -> Non
search_mock = mocker.patch("aur.search") search_mock = mocker.patch("aur.search")
response = await client.get("/service-api/v1/search", params=[("for", "ah"), ("for", "maybe")]) response = await client.get("/service-api/v1/search", params=[("for", "ah"), ("for", "maybe")])
assert response.status == 200 assert response.ok
search_mock.assert_called_with("maybe") search_mock.assert_called_with("maybe")

View File

@ -11,7 +11,7 @@ async def test_get(client: TestClient) -> None:
response = await client.get("/status-api/v1/ahriman") response = await client.get("/status-api/v1/ahriman")
status = BuildStatus.from_json(await response.json()) status = BuildStatus.from_json(await response.json())
assert response.status == 200 assert response.ok
assert status.status == BuildStatusEnum.Unknown assert status.status == BuildStatusEnum.Unknown
@ -26,7 +26,7 @@ async def test_post(client: TestClient) -> None:
response = await client.get("/status-api/v1/ahriman") response = await client.get("/status-api/v1/ahriman")
status = BuildStatus.from_json(await response.json()) status = BuildStatus.from_json(await response.json())
assert response.status == 200 assert response.ok
assert status.status == BuildStatusEnum.Success assert status.status == BuildStatusEnum.Success

View File

@ -14,7 +14,7 @@ async def test_get(client: TestClient, package_ahriman: Package, package_python_
json={"status": BuildStatusEnum.Success.value, "package": package_python_schedule.view()}) json={"status": BuildStatusEnum.Success.value, "package": package_python_schedule.view()})
response = await client.get(f"/status-api/v1/packages/{package_ahriman.base}") response = await client.get(f"/status-api/v1/packages/{package_ahriman.base}")
assert response.status == 200 assert response.ok
packages = [Package.from_json(item["package"]) for item in await response.json()] packages = [Package.from_json(item["package"]) for item in await response.json()]
assert packages assert packages
@ -45,7 +45,7 @@ async def test_delete(client: TestClient, package_ahriman: Package, package_pyth
assert response.status == 404 assert response.status == 404
response = await client.get(f"/status-api/v1/packages/{package_python_schedule.base}") response = await client.get(f"/status-api/v1/packages/{package_python_schedule.base}")
assert response.status == 200 assert response.ok
async def test_delete_unknown(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None: async def test_delete_unknown(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None:
@ -62,7 +62,7 @@ async def test_delete_unknown(client: TestClient, package_ahriman: Package, pack
assert response.status == 404 assert response.status == 404
response = await client.get(f"/status-api/v1/packages/{package_python_schedule.base}") response = await client.get(f"/status-api/v1/packages/{package_python_schedule.base}")
assert response.status == 200 assert response.ok
async def test_post(client: TestClient, package_ahriman: Package) -> None: async def test_post(client: TestClient, package_ahriman: Package) -> None:
@ -75,7 +75,7 @@ async def test_post(client: TestClient, package_ahriman: Package) -> None:
assert post_response.status == 204 assert post_response.status == 204
response = await client.get(f"/status-api/v1/packages/{package_ahriman.base}") response = await client.get(f"/status-api/v1/packages/{package_ahriman.base}")
assert response.status == 200 assert response.ok
async def test_post_exception(client: TestClient, package_ahriman: Package) -> None: async def test_post_exception(client: TestClient, package_ahriman: Package) -> None:
@ -100,7 +100,7 @@ async def test_post_light(client: TestClient, package_ahriman: Package) -> None:
assert post_response.status == 204 assert post_response.status == 204
response = await client.get(f"/status-api/v1/packages/{package_ahriman.base}") response = await client.get(f"/status-api/v1/packages/{package_ahriman.base}")
assert response.status == 200 assert response.ok
statuses = { statuses = {
Package.from_json(item["package"]).base: BuildStatus.from_json(item["status"]) Package.from_json(item["package"]).base: BuildStatus.from_json(item["status"])
for item in await response.json() for item in await response.json()

View File

@ -15,7 +15,7 @@ async def test_get(client: TestClient, package_ahriman: Package, package_python_
json={"status": BuildStatusEnum.Success.value, "package": package_python_schedule.view()}) json={"status": BuildStatusEnum.Success.value, "package": package_python_schedule.view()})
response = await client.get("/status-api/v1/packages") response = await client.get("/status-api/v1/packages")
assert response.status == 200 assert response.ok
packages = [Package.from_json(item["package"]) for item in await response.json()] packages = [Package.from_json(item["package"]) for item in await response.json()]
assert packages assert packages

View File

@ -14,7 +14,7 @@ async def test_get(client: TestClient, package_ahriman: Package) -> None:
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()}) json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
response = await client.get("/status-api/v1/status") response = await client.get("/status-api/v1/status")
assert response.status == 200 assert response.ok
json = await response.json() json = await response.json()
assert json["version"] == version.__version__ assert json["version"] == version.__version__

View File

@ -6,7 +6,7 @@ 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("/") response = await client_with_auth.get("/")
assert response.status == 200 assert response.ok
assert await response.text() assert await response.text()
@ -15,7 +15,7 @@ async def test_get_index(client_with_auth: TestClient) -> None:
must generate status page correctly (/index.html) must generate status page correctly (/index.html)
""" """
response = await client_with_auth.get("/index.html") response = await client_with_auth.get("/index.html")
assert response.status == 200 assert response.ok
assert await response.text() assert await response.text()
@ -24,7 +24,7 @@ async def test_get_without_auth(client: TestClient) -> None:
must use dummy authorized_userid function in case if no security library installed 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.ok
assert await response.text() assert await response.text()
@ -33,4 +33,4 @@ async def test_get_static(client: TestClient) -> None:
must return static files must return static files
""" """
response = await client.get("/static/favicon.ico") response = await client.get("/static/favicon.ico")
assert response.status == 200 assert response.ok

View File

@ -1,9 +1,75 @@
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from unittest.mock import MagicMock
from ahriman.core.auth.oauth import OAuth
from ahriman.models.user import User from ahriman.models.user import User
async def test_get_default_validator(client_with_auth: TestClient) -> None:
"""
must return 405 in case if no OAuth enabled
"""
get_response = await client_with_auth.get("/user-api/v1/login")
assert get_response.status == 405
async def test_get_redirect_to_oauth(client_with_auth: TestClient) -> None:
"""
must redirect to OAuth service provider in case if no code is supplied
"""
oauth = client_with_auth.app["validator"] = MagicMock(spec=OAuth)
oauth.get_oauth_url.return_value = "https://example.com"
get_response = await client_with_auth.get("/user-api/v1/login")
assert get_response.ok
oauth.get_oauth_url.assert_called_once()
async def test_get_redirect_to_oauth_empty_code(client_with_auth: TestClient) -> None:
"""
must redirect to OAuth service provider in case if empty code is supplied
"""
oauth = client_with_auth.app["validator"] = MagicMock(spec=OAuth)
oauth.get_oauth_url.return_value = "https://example.com"
get_response = await client_with_auth.get("/user-api/v1/login", params={"code": ""})
assert get_response.ok
oauth.get_oauth_url.assert_called_once()
async def test_get(client_with_auth: TestClient, mocker: MockerFixture) -> None:
"""
must login user correctly from OAuth
"""
oauth = client_with_auth.app["validator"] = MagicMock(spec=OAuth)
oauth.get_oauth_username.return_value = "user"
oauth.known_username.return_value = True
oauth.enabled = False # lol
remember_mock = mocker.patch("aiohttp_security.remember")
get_response = await client_with_auth.get("/user-api/v1/login", params={"code": "code"})
assert get_response.ok
oauth.get_oauth_username.assert_called_with("code")
oauth.known_username.assert_called_with("user")
remember_mock.assert_called_once()
async def test_get_unauthorized(client_with_auth: TestClient, mocker: MockerFixture) -> None:
"""
must return unauthorized from OAuth
"""
oauth = client_with_auth.app["validator"] = MagicMock(spec=OAuth)
oauth.known_username.return_value = False
remember_mock = mocker.patch("aiohttp_security.remember")
get_response = await client_with_auth.get("/user-api/v1/login", params={"code": "code"})
assert get_response.status == 401
remember_mock.assert_not_called()
async def test_post(client_with_auth: TestClient, user: User, mocker: MockerFixture) -> None: async def test_post(client_with_auth: TestClient, user: User, mocker: MockerFixture) -> None:
""" """
must login user correctly must login user correctly
@ -12,10 +78,10 @@ async def test_post(client_with_auth: TestClient, user: User, mocker: MockerFixt
remember_mock = mocker.patch("aiohttp_security.remember") remember_mock = mocker.patch("aiohttp_security.remember")
post_response = await client_with_auth.post("/user-api/v1/login", json=payload) post_response = await client_with_auth.post("/user-api/v1/login", json=payload)
assert post_response.status == 200 assert post_response.ok
post_response = await client_with_auth.post("/user-api/v1/login", data=payload) post_response = await client_with_auth.post("/user-api/v1/login", data=payload)
assert post_response.status == 200 assert post_response.ok
remember_mock.assert_called() remember_mock.assert_called()
@ -26,7 +92,7 @@ async def test_post_skip(client: TestClient, user: User) -> None:
""" """
payload = {"username": user.username, "password": user.password} payload = {"username": user.username, "password": user.password}
post_response = await client.post("/user-api/v1/login", json=payload) post_response = await client.post("/user-api/v1/login", json=payload)
assert post_response.status == 200 assert post_response.ok
async def test_post_unauthorized(client_with_auth: TestClient, user: User, mocker: MockerFixture) -> None: async def test_post_unauthorized(client_with_auth: TestClient, user: User, mocker: MockerFixture) -> None:

View File

@ -11,7 +11,7 @@ async def test_post(client_with_auth: TestClient, mocker: MockerFixture) -> None
forget_mock = mocker.patch("aiohttp_security.forget") forget_mock = mocker.patch("aiohttp_security.forget")
post_response = await client_with_auth.post("/user-api/v1/logout") post_response = await client_with_auth.post("/user-api/v1/logout")
assert post_response.status == 200 assert post_response.ok
forget_mock.assert_called_once() forget_mock.assert_called_once()
@ -32,4 +32,4 @@ async def test_post_disabled(client: TestClient) -> None:
must raise exception if auth is disabled must raise exception if auth is disabled
""" """
post_response = await client.post("/user-api/v1/logout") post_response = await client.post("/user-api/v1/logout")
assert post_response.status == 200 assert post_response.ok

View File

@ -10,6 +10,10 @@ root = /
[auth] [auth]
allow_read_only = no allow_read_only = no
client_id = client_id
client_secret = client_secret
oauth_provider = GoogleClient
oauth_scopes = https://www.googleapis.com/auth/userinfo.email
salt = salt salt = salt
[build] [build]