* 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 1b29b5773d
commit d19deb57e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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.
## 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
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:
* 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
@ -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).
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

View File

@ -20,13 +20,17 @@ libalpm and AUR related configuration.
## `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.
* `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.
* `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.
* `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).
## `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:
* `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
@ -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.
* `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.
* `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.

View File

@ -13,6 +13,7 @@ optdepends=('breezy: -bzr packages support'
'darcs: -darcs packages support'
'gnupg: package and repository sign'
'mercurial: -hg packages support'
'python-aioauth-client: web server with OAuth2 authorization'
'python-aiohttp: web server'
'python-aiohttp-jinja2: web server'
'python-aiohttp-security: web server with authorization'

View File

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

View File

@ -14,7 +14,7 @@
<div class="container">
<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/repository-{{ repository }}-informational" alt="{{ repository }}">
<img src="https://img.shields.io/badge/architecture-{{ architecture }}-informational" alt="{{ architecture }}">
@ -25,7 +25,7 @@
<div class="container">
<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">
<i class="fa fa-plus"></i> Add
</button>
@ -70,7 +70,7 @@
</thead>
<tbody>
{% if authorized %}
{% if auth.authenticated %}
{% for package in packages %}
<tr data-package-base="{{ package.base }}">
<td data-checkbox="true"></td>
@ -85,7 +85,7 @@
{% endfor %}
{% else %}
<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>
{% endif %}
</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>
</ul>
{% if auth_enabled %}
{% 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>
{% if auth.enabled %}
{% if auth.username is none %}
{{ auth.control|safe }}
{% else %}
<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>
{% endif %}
{% endif %}
</footer>
</div>
{% if auth_enabled %}
{% if auth.enabled %}
{% include "build-status/login-modal.jinja2" %}
{% endif %}

View File

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

View File

@ -19,10 +19,14 @@
#
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.exceptions import DuplicateUser
from ahriman.models.auth_settings import AuthSettings
from ahriman.models.user import User
from ahriman.models.user_access import UserAccess
@ -45,6 +49,8 @@ class Auth:
:param configuration: configuration instance
:param provider: authorization type definition
"""
self.logger = logging.getLogger("http")
self.allow_read_only = configuration.getboolean("auth", "allow_read_only")
self.allowed_paths = set(configuration.getlist("auth", "allowed_paths"))
self.allowed_paths.update(self.ALLOWED_PATHS)
@ -53,6 +59,17 @@ class Auth:
self.enabled = provider.is_enabled
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
def load(cls: Type[Auth], configuration: Configuration) -> Auth:
"""
@ -62,11 +79,33 @@ class Auth:
"""
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)
from ahriman.core.auth.mapping import Mapping
return Mapping(configuration)
if provider == AuthSettings.OAuth:
from ahriman.core.auth.oauth import OAuth
return OAuth(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
:param username: username
@ -76,20 +115,20 @@ class Auth:
del username, password
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
:param uri: request uri
:param required: required access level
: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:
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)
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
:param username: username
@ -98,7 +137,7 @@ class Auth:
del username
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
:param username: username

View File

@ -17,17 +17,16 @@
# 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 typing import 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):
class Mapping(Auth):
"""
user authorization based on mapping from configuration file
:ivar salt: random generated string to salt passwords
@ -44,26 +43,7 @@ class MappingAuth(Auth):
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:
async def check_credentials(self, username: Optional[str], password: Optional[str]) -> bool:
"""
validate user password
:param username: username
@ -84,15 +64,15 @@ class MappingAuth(Auth):
normalized_user = username.lower()
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
: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
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
:param username: username
@ -100,6 +80,5 @@ class MappingAuth(Auth):
: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

@ -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
"""
def __init__(self) -> None:
def __init__(self, details: str) -> None:
"""
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):

View File

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

View File

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

View File

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

View File

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

View File

@ -35,6 +35,7 @@ class User:
:ivar password: hashed user password with salt
:ivar access: user role
"""
username: str
password: str
access: UserAccess
@ -42,16 +43,18 @@ class User:
_HASHER = sha512_crypt
@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
:param username: username
:param password: password as string
:param access: optional user access
: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.Read)
return cls(username, password, access)
@staticmethod
def generate_password(length: int) -> str:
@ -70,7 +73,10 @@ class User:
:param salt: salt for hashed password
: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
def hash_password(self, salt: str) -> str:
@ -79,6 +85,10 @@ class User:
:param salt: salt for hashed password
: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)
return password_hash

View File

@ -48,11 +48,11 @@ class AuthorizationPolicy(aiohttp_security.AbstractAuthorizationPolicy): # type
async def authorized_userid(self, identity: str) -> Optional[str]:
"""
retrieve authorized username
retrieve authenticated 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
return identity if await self.validator.known_username(identity) else None
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
: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:
@ -78,7 +78,7 @@ def auth_handler(validator: Auth) -> MiddlewareType:
else:
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)
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 /user-api/v1/login OAuth2 handler for login
POST /user-api/v1/login login to 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("/user-api/v1/login", LoginView)
application.router.add_post("/user-api/v1/login", LoginView)
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:
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
auth - authorization descriptor, required
* authenticated - alias to check if user can see the page, boolean, required
* 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
* base, string
* depends, sorted list of strings
@ -74,24 +76,27 @@ class IndexView(BaseView):
"status_color": status.status.bootstrap_color(),
"timestamp": pretty_datetime(status.timestamp),
"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)
]
service = {
"status": self.service.status.status.value,
"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_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 {
"architecture": self.service.architecture,
"authorized": authorized,
"auth_enabled": self.validator.enabled,
"auth_username": auth_username,
"auth": auth,
"packages": packages,
"repository": self.service.repository.name,
"service": service,

View File

@ -17,7 +17,7 @@
# 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 aiohttp.web import HTTPFound, HTTPMethodNotAllowed, HTTPUnauthorized, Response
from ahriman.core.auth.helpers import remember
from ahriman.web.views.base import BaseView
@ -28,6 +28,33 @@ class LoginView(BaseView):
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:
"""
login user to service
@ -44,7 +71,7 @@ class LoginView(BaseView):
username = data.get("username")
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)
return response

View File

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

View File

@ -1,13 +1,26 @@
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
@pytest.fixture
def mapping_auth(configuration: Configuration) -> MappingAuth:
def mapping(configuration: Configuration) -> Mapping:
"""
auth provider fixture
:param configuration: configuration fixture
: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.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.exceptions import DuplicateUser
from ahriman.models.user import User
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:
"""
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")
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
"""
assert auth.check_credentials(user.username, user.password)
assert auth.check_credentials(None, "")
assert auth.check_credentials("", None)
assert auth.check_credentials(None, None)
assert await auth.check_credentials(user.username, user.password)
assert await auth.check_credentials(None, "")
assert await auth.check_credentials("", 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
"""
# login and logout are always safe
assert 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/login", UserAccess.Write)
assert await auth.is_safe_request("/user-api/v1/logout", UserAccess.Write)
auth.allowed_paths.add("/safe")
auth.allowed_paths_groups.add("/unsafe/safe")
assert auth.is_safe_request("/safe", UserAccess.Write)
assert not auth.is_safe_request("/unsafe", UserAccess.Write)
assert auth.is_safe_request("/unsafe/safe", UserAccess.Write)
assert auth.is_safe_request("/unsafe/safe/suffix", UserAccess.Write)
assert await auth.is_safe_request("/safe", UserAccess.Write)
assert not await auth.is_safe_request("/unsafe", UserAccess.Write)
assert await auth.is_safe_request("/unsafe/safe", 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
"""
assert not auth.is_safe_request(None, UserAccess.Read)
assert not auth.is_safe_request("", UserAccess.Read)
assert not await auth.is_safe_request(None, 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
"""
assert auth.is_safe_request("/", UserAccess.Read)
assert await auth.is_safe_request("/", UserAccess.Read)
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
"""
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
"""
assert auth.verify_access(user.username, user.access, None)
assert auth.verify_access(user.username, UserAccess.Write, None)
assert await auth.verify_access(user.username, user.access, 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("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("mapping") == AuthSettings.Configuration

View File

@ -10,6 +10,7 @@ def test_from_option(user: User) -> None:
# default is read access
user.access = UserAccess.Write
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:
@ -32,6 +33,26 @@ def test_check_credentials_hash_password(user: User) -> None:
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:
"""
must generate password with specified length

View File

@ -2,10 +2,9 @@ import pytest
from aiohttp import web
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.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
@ -23,7 +22,7 @@ async def test_permits(authorization_policy: AuthorizationPolicy, user: User) ->
"""
must call validator check
"""
authorization_policy.validator = MagicMock()
authorization_policy.validator = AsyncMock()
authorization_policy.validator.verify_access.return_value = True
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")
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)
@ -20,7 +20,7 @@ async def test_post_now(client: TestClient, mocker: MockerFixture) -> None:
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add")
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)
@ -42,5 +42,5 @@ async def test_post_update(client: TestClient, mocker: MockerFixture) -> None:
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add")
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)

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")
response = await client.post("/service-api/v1/remove", json={"packages": ["ahriman"]})
assert response.status == 200
assert response.ok
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])
response = await client.get("/service-api/v1/search", params={"for": "ahriman"})
assert response.status == 200
assert response.ok
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")
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")
@ -44,7 +44,7 @@ async def test_get_join_filter(client: TestClient, mocker: MockerFixture) -> Non
search_mock = mocker.patch("aur.search")
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")

View File

@ -11,7 +11,7 @@ async def test_get(client: TestClient) -> None:
response = await client.get("/status-api/v1/ahriman")
status = BuildStatus.from_json(await response.json())
assert response.status == 200
assert response.ok
assert status.status == BuildStatusEnum.Unknown
@ -26,7 +26,7 @@ async def test_post(client: TestClient) -> None:
response = await client.get("/status-api/v1/ahriman")
status = BuildStatus.from_json(await response.json())
assert response.status == 200
assert response.ok
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()})
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()]
assert packages
@ -45,7 +45,7 @@ async def test_delete(client: TestClient, package_ahriman: Package, package_pyth
assert response.status == 404
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:
@ -62,7 +62,7 @@ async def test_delete_unknown(client: TestClient, package_ahriman: Package, pack
assert response.status == 404
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:
@ -75,7 +75,7 @@ async def test_post(client: TestClient, package_ahriman: Package) -> None:
assert post_response.status == 204
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:
@ -100,7 +100,7 @@ async def test_post_light(client: TestClient, package_ahriman: Package) -> None:
assert post_response.status == 204
response = await client.get(f"/status-api/v1/packages/{package_ahriman.base}")
assert response.status == 200
assert response.ok
statuses = {
Package.from_json(item["package"]).base: BuildStatus.from_json(item["status"])
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()})
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()]
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()})
response = await client.get("/status-api/v1/status")
assert response.status == 200
assert response.ok
json = await response.json()
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 (/)
"""
response = await client_with_auth.get("/")
assert response.status == 200
assert response.ok
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)
"""
response = await client_with_auth.get("/index.html")
assert response.status == 200
assert response.ok
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
"""
response = await client.get("/")
assert response.status == 200
assert response.ok
assert await response.text()
@ -33,4 +33,4 @@ async def test_get_static(client: TestClient) -> None:
must return static files
"""
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 pytest_mock import MockerFixture
from unittest.mock import MagicMock
from ahriman.core.auth.oauth import OAuth
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:
"""
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")
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)
assert post_response.status == 200
assert post_response.ok
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}
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:

View File

@ -11,7 +11,7 @@ async def test_post(client_with_auth: TestClient, mocker: MockerFixture) -> None
forget_mock = mocker.patch("aiohttp_security.forget")
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()
@ -32,4 +32,4 @@ async def test_post_disabled(client: TestClient) -> None:
must raise exception if auth is disabled
"""
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]
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
[build]