mirror of
https://github.com/arcan1s/ahriman.git
synced 2025-07-10 04:25:47 +00:00
Auth support (#25)
* initial auth implementation * add create user parser * add tests * update dependencies list * add login annd logout to index also improve auth * realworld fixes * add method set_option to Configuration and also use it everywhere * split CreateUser handler to additional read method * check user duplicate on auth mapping read * generate salt by using passlib instead of random.choice * case-insensetive usernames * update dependencies * update configuration reference * improve tests * fix codefactor errors * hide fields if authorization is enabled, but no auth supplied * add settings object for auth provider * readme update
This commit is contained in:
@ -30,6 +30,8 @@ from ahriman.models.sign_settings import SignSettings
|
||||
|
||||
|
||||
# pylint thinks it is bad idea, but get the fuck off
|
||||
from ahriman.models.user_access import UserAccess
|
||||
|
||||
SubParserAction = argparse._SubParsersAction # pylint: disable=protected-access
|
||||
|
||||
|
||||
@ -61,6 +63,7 @@ def _parser() -> argparse.ArgumentParser:
|
||||
_set_check_parser(subparsers)
|
||||
_set_clean_parser(subparsers)
|
||||
_set_config_parser(subparsers)
|
||||
_set_create_user_parser(subparsers)
|
||||
_set_init_parser(subparsers)
|
||||
_set_key_import_parser(subparsers)
|
||||
_set_rebuild_parser(subparsers)
|
||||
@ -138,6 +141,30 @@ def _set_config_parser(root: SubParserAction) -> argparse.ArgumentParser:
|
||||
return parser
|
||||
|
||||
|
||||
def _set_create_user_parser(root: SubParserAction) -> argparse.ArgumentParser:
|
||||
"""
|
||||
add parser for create user subcommand
|
||||
:param root: subparsers for the commands
|
||||
:return: created argument parser
|
||||
"""
|
||||
parser = root.add_parser(
|
||||
"create-user",
|
||||
help="create user for web services",
|
||||
description="create user for web services with password and role. In case if password was not entered it will be asked interactively",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||
parser.add_argument("username", help="username for web service")
|
||||
parser.add_argument("-r", "--role", help="user role", type=UserAccess, choices=UserAccess, default=UserAccess.Read)
|
||||
parser.add_argument("-p", "--password", help="user password")
|
||||
parser.set_defaults(
|
||||
handler=handlers.CreateUser,
|
||||
architecture=[""],
|
||||
lock=None,
|
||||
no_log=True,
|
||||
no_report=True,
|
||||
unsafe=True)
|
||||
return parser
|
||||
|
||||
|
||||
def _set_init_parser(root: SubParserAction) -> argparse.ArgumentParser:
|
||||
"""
|
||||
add parser for init subcommand
|
||||
|
@ -21,6 +21,7 @@ from ahriman.application.handlers.handler import Handler
|
||||
|
||||
from ahriman.application.handlers.add import Add
|
||||
from ahriman.application.handlers.clean import Clean
|
||||
from ahriman.application.handlers.create_user import CreateUser
|
||||
from ahriman.application.handlers.dump import Dump
|
||||
from ahriman.application.handlers.init import Init
|
||||
from ahriman.application.handlers.key_import import KeyImport
|
||||
|
105
src/ahriman/application/handlers/create_user.py
Normal file
105
src/ahriman/application/handlers/create_user.py
Normal file
@ -0,0 +1,105 @@
|
||||
#
|
||||
# Copyright (c) 2021 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
import argparse
|
||||
import getpass
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Type
|
||||
|
||||
from ahriman.application.handlers.handler import Handler
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.models.user import User
|
||||
|
||||
|
||||
class CreateUser(Handler):
|
||||
"""
|
||||
create user handler
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
|
||||
"""
|
||||
callback for command line
|
||||
:param args: command line args
|
||||
:param architecture: repository architecture
|
||||
:param configuration: configuration instance
|
||||
"""
|
||||
salt = CreateUser.get_salt(configuration)
|
||||
user = CreateUser.create_user(args, salt)
|
||||
auth_configuration = CreateUser.get_auth_configuration(configuration.include)
|
||||
CreateUser.create_configuration(auth_configuration, user, salt)
|
||||
|
||||
@staticmethod
|
||||
def create_configuration(configuration: Configuration, user: User, salt: str) -> None:
|
||||
"""
|
||||
put new user to configuration
|
||||
:param configuration: configuration instance
|
||||
:param user: user descriptor
|
||||
:param salt: password hash salt
|
||||
"""
|
||||
section = Configuration.section_name("auth", user.access.value)
|
||||
configuration.set_option("auth", "salt", salt)
|
||||
configuration.set_option(section, user.username, user.password)
|
||||
|
||||
if configuration.path is None:
|
||||
return
|
||||
with configuration.path.open("w") as ahriman_configuration:
|
||||
configuration.write(ahriman_configuration)
|
||||
|
||||
@staticmethod
|
||||
def create_user(args: argparse.Namespace, salt: str) -> User:
|
||||
"""
|
||||
create user descriptor from arguments
|
||||
:param args: command line args
|
||||
:param salt: password hash salt
|
||||
:return: built user descriptor
|
||||
"""
|
||||
user = User(args.username, args.password, args.role)
|
||||
if user.password is None:
|
||||
user.password = getpass.getpass()
|
||||
user.password = user.hash_password(user.password, salt)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def get_auth_configuration(include_path: Path) -> Configuration:
|
||||
"""
|
||||
create configuration instance
|
||||
:param include_path: path to directory with configuration includes
|
||||
:return: configuration instance. In case if there are local settings they will be loaded
|
||||
"""
|
||||
target = include_path / "auth.ini"
|
||||
configuration = Configuration()
|
||||
if target.is_file(): # load current configuration in case if it exists
|
||||
configuration.load(target)
|
||||
|
||||
return configuration
|
||||
|
||||
@staticmethod
|
||||
def get_salt(configuration: Configuration, salt_length: int = 20) -> str:
|
||||
"""
|
||||
get salt from configuration or create new string
|
||||
:param configuration: configuration instance
|
||||
:param salt_length: salt length
|
||||
:return: current salt
|
||||
"""
|
||||
salt = configuration.get("auth", "salt", fallback=None)
|
||||
if salt:
|
||||
return salt
|
||||
return User.generate_password(salt_length)
|
@ -18,7 +18,6 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
import argparse
|
||||
import configparser
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Type
|
||||
@ -79,25 +78,20 @@ class Setup(Handler):
|
||||
:param repository: repository name
|
||||
:param include_path: path to directory with configuration includes
|
||||
"""
|
||||
configuration = configparser.ConfigParser()
|
||||
configuration = Configuration()
|
||||
|
||||
section = Configuration.section_name("build", architecture)
|
||||
configuration.add_section(section)
|
||||
configuration.set(section, "build_command", str(Setup.build_command(args.build_command, architecture)))
|
||||
|
||||
configuration.add_section("repository")
|
||||
configuration.set("repository", "name", repository)
|
||||
configuration.set_option(section, "build_command", str(Setup.build_command(args.build_command, architecture)))
|
||||
configuration.set_option("repository", "name", repository)
|
||||
|
||||
if args.sign_key is not None:
|
||||
section = Configuration.section_name("sign", architecture)
|
||||
configuration.add_section(section)
|
||||
configuration.set(section, "target", " ".join([target.name.lower() for target in args.sign_target]))
|
||||
configuration.set(section, "key", args.sign_key)
|
||||
configuration.set_option(section, "target", " ".join([target.name.lower() for target in args.sign_target]))
|
||||
configuration.set_option(section, "key", args.sign_key)
|
||||
|
||||
if args.web_port is not None:
|
||||
section = Configuration.section_name("web", architecture)
|
||||
configuration.add_section(section)
|
||||
configuration.set(section, "port", str(args.web_port))
|
||||
configuration.set_option(section, "port", str(args.web_port))
|
||||
|
||||
target = include_path / "setup-overrides.ini"
|
||||
with target.open("w") as ahriman_configuration:
|
||||
@ -115,7 +109,7 @@ class Setup(Handler):
|
||||
:param repository: repository name
|
||||
:param paths: repository paths instance
|
||||
"""
|
||||
configuration = configparser.ConfigParser()
|
||||
configuration = Configuration()
|
||||
# preserve case
|
||||
# stupid mypy thinks that it is impossible
|
||||
configuration.optionxform = lambda key: key # type: ignore
|
||||
@ -125,17 +119,15 @@ class Setup(Handler):
|
||||
configuration.read(source)
|
||||
|
||||
# set our architecture now
|
||||
configuration.set("options", "Architecture", architecture)
|
||||
configuration.set_option("options", "Architecture", architecture)
|
||||
|
||||
# add multilib
|
||||
if not no_multilib:
|
||||
configuration.add_section("multilib")
|
||||
configuration.set("multilib", "Include", str(Setup.MIRRORLIST_PATH))
|
||||
configuration.set_option("multilib", "Include", str(Setup.MIRRORLIST_PATH))
|
||||
|
||||
# add repository itself
|
||||
configuration.add_section(repository)
|
||||
configuration.set(repository, "SigLevel", "Optional TrustAll") # we don't care
|
||||
configuration.set(repository, "Server", f"file://{paths.repository}")
|
||||
configuration.set_option(repository, "SigLevel", "Optional TrustAll") # we don't care
|
||||
configuration.set_option(repository, "Server", f"file://{paths.repository}")
|
||||
|
||||
target = source.parent / f"pacman-{prefix}-{architecture}.conf"
|
||||
with target.open("w") as devtools_configuration:
|
||||
|
0
src/ahriman/core/auth/__init__.py
Normal file
0
src/ahriman/core/auth/__init__.py
Normal file
105
src/ahriman/core/auth/auth.py
Normal file
105
src/ahriman/core/auth/auth.py
Normal file
@ -0,0 +1,105 @@
|
||||
#
|
||||
# Copyright (c) 2021 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Set, Type
|
||||
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.models.auth_settings import AuthSettings
|
||||
from ahriman.models.user_access import UserAccess
|
||||
|
||||
|
||||
class Auth:
|
||||
"""
|
||||
helper to deal with user authorization
|
||||
:ivar allowed_paths: URI paths which can be accessed without authorization
|
||||
:ivar allowed_paths_groups: URI paths prefixes which can be accessed without authorization
|
||||
:ivar enabled: indicates if authorization is enabled
|
||||
:cvar ALLOWED_PATHS: URI paths which can be accessed without authorization, predefined
|
||||
:cvar ALLOWED_PATHS_GROUPS: URI paths prefixes which can be accessed without authorization, predefined
|
||||
"""
|
||||
|
||||
ALLOWED_PATHS = {"/", "/favicon.ico", "/index.html", "/login", "/logout"}
|
||||
ALLOWED_PATHS_GROUPS: Set[str] = set()
|
||||
|
||||
def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.Disabled) -> None:
|
||||
"""
|
||||
default constructor
|
||||
:param configuration: configuration instance
|
||||
:param provider: authorization type definition
|
||||
"""
|
||||
self.allowed_paths = set(configuration.getlist("auth", "allowed_paths"))
|
||||
self.allowed_paths.update(self.ALLOWED_PATHS)
|
||||
self.allowed_paths_groups = set(configuration.getlist("auth", "allowed_paths_groups"))
|
||||
self.allowed_paths_groups.update(self.ALLOWED_PATHS_GROUPS)
|
||||
self.enabled = provider.is_enabled
|
||||
|
||||
@classmethod
|
||||
def load(cls: Type[Auth], configuration: Configuration) -> Auth:
|
||||
"""
|
||||
load authorization module from settings
|
||||
:param configuration: configuration instance
|
||||
:return: authorization module according to current settings
|
||||
"""
|
||||
provider = AuthSettings.from_option(configuration.get("auth", "target", fallback="disabled"))
|
||||
if provider == AuthSettings.Configuration:
|
||||
from ahriman.core.auth.mapping_auth import MappingAuth
|
||||
return MappingAuth(configuration)
|
||||
return cls(configuration)
|
||||
|
||||
def check_credentials(self, username: Optional[str], password: Optional[str]) -> bool: # pylint: disable=no-self-use
|
||||
"""
|
||||
validate user password
|
||||
:param username: username
|
||||
:param password: entered password
|
||||
:return: True in case if password matches, False otherwise
|
||||
"""
|
||||
del username, password
|
||||
return True
|
||||
|
||||
def is_safe_request(self, uri: Optional[str]) -> bool:
|
||||
"""
|
||||
check if requested path are allowed without authorization
|
||||
:param uri: request uri
|
||||
:return: True in case if this URI can be requested without authorization and False otherwise
|
||||
"""
|
||||
if not uri:
|
||||
return False # request without context is not allowed
|
||||
return uri in self.allowed_paths or any(uri.startswith(path) for path in self.allowed_paths_groups)
|
||||
|
||||
def known_username(self, username: str) -> bool: # pylint: disable=no-self-use
|
||||
"""
|
||||
check if user is known
|
||||
:param username: username
|
||||
:return: True in case if user is known and can be authorized and False otherwise
|
||||
"""
|
||||
del username
|
||||
return True
|
||||
|
||||
def verify_access(self, username: str, required: UserAccess, context: Optional[str]) -> bool: # pylint: disable=no-self-use
|
||||
"""
|
||||
validate if user has access to requested resource
|
||||
:param username: username
|
||||
:param required: required access level
|
||||
:param context: URI request path
|
||||
:return: True in case if user is allowed to do this request and False otherwise
|
||||
"""
|
||||
del username, required, context
|
||||
return True
|
70
src/ahriman/core/auth/helpers.py
Normal file
70
src/ahriman/core/auth/helpers.py
Normal file
@ -0,0 +1,70 @@
|
||||
#
|
||||
# Copyright (c) 2021 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import aiohttp_security # type: ignore
|
||||
_has_aiohttp_security = True
|
||||
except ImportError:
|
||||
_has_aiohttp_security = False
|
||||
|
||||
|
||||
async def authorized_userid(*args: Any) -> Any:
|
||||
"""
|
||||
handle aiohttp security methods
|
||||
:param args: argument list as provided by authorized_userid function
|
||||
:return: None in case if no aiohttp_security module found and function call otherwise
|
||||
"""
|
||||
if _has_aiohttp_security:
|
||||
return await aiohttp_security.authorized_userid(*args) # pylint: disable=no-value-for-parameter
|
||||
return None
|
||||
|
||||
|
||||
async def check_authorized(*args: Any) -> Any:
|
||||
"""
|
||||
handle aiohttp security methods
|
||||
:param args: argument list as provided by check_authorized function
|
||||
:return: None in case if no aiohttp_security module found and function call otherwise
|
||||
"""
|
||||
if _has_aiohttp_security:
|
||||
return await aiohttp_security.check_authorized(*args) # pylint: disable=no-value-for-parameter
|
||||
return None
|
||||
|
||||
|
||||
async def forget(*args: Any) -> Any:
|
||||
"""
|
||||
handle aiohttp security methods
|
||||
:param args: argument list as provided by forget function
|
||||
:return: None in case if no aiohttp_security module found and function call otherwise
|
||||
"""
|
||||
if _has_aiohttp_security:
|
||||
return await aiohttp_security.forget(*args) # pylint: disable=no-value-for-parameter
|
||||
return None
|
||||
|
||||
|
||||
async def remember(*args: Any) -> Any:
|
||||
"""
|
||||
handle disabled auth
|
||||
:param args: argument list as provided by remember function
|
||||
:return: None in case if no aiohttp_security module found and function call otherwise
|
||||
"""
|
||||
if _has_aiohttp_security:
|
||||
return await aiohttp_security.remember(*args) # pylint: disable=no-value-for-parameter
|
||||
return None
|
105
src/ahriman/core/auth/mapping_auth.py
Normal file
105
src/ahriman/core/auth/mapping_auth.py
Normal file
@ -0,0 +1,105 @@
|
||||
#
|
||||
# Copyright (c) 2021 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
from typing import Dict, Optional
|
||||
|
||||
from ahriman.core.auth.auth import Auth
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.exceptions import DuplicateUser
|
||||
from ahriman.models.auth_settings import AuthSettings
|
||||
from ahriman.models.user import User
|
||||
from ahriman.models.user_access import UserAccess
|
||||
|
||||
|
||||
class MappingAuth(Auth):
|
||||
"""
|
||||
user authorization based on mapping from configuration file
|
||||
:ivar salt: random generated string to salt passwords
|
||||
:ivar _users: map of username to its descriptor
|
||||
"""
|
||||
|
||||
def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.Configuration) -> None:
|
||||
"""
|
||||
default constructor
|
||||
:param configuration: configuration instance
|
||||
:param provider: authorization type definition
|
||||
"""
|
||||
Auth.__init__(self, configuration, provider)
|
||||
self.salt = configuration.get("auth", "salt")
|
||||
self._users = self.get_users(configuration)
|
||||
|
||||
@staticmethod
|
||||
def get_users(configuration: Configuration) -> Dict[str, User]:
|
||||
"""
|
||||
load users from settings
|
||||
:param configuration: configuration instance
|
||||
:return: map of username to its descriptor
|
||||
"""
|
||||
users: Dict[str, User] = {}
|
||||
for role in UserAccess:
|
||||
section = configuration.section_name("auth", role.value)
|
||||
if not configuration.has_section(section):
|
||||
continue
|
||||
for user, password in configuration[section].items():
|
||||
normalized_user = user.lower()
|
||||
if normalized_user in users:
|
||||
raise DuplicateUser(normalized_user)
|
||||
users[normalized_user] = User(normalized_user, password, role)
|
||||
return users
|
||||
|
||||
def check_credentials(self, username: Optional[str], password: Optional[str]) -> bool:
|
||||
"""
|
||||
validate user password
|
||||
:param username: username
|
||||
:param password: entered password
|
||||
:return: True in case if password matches, False otherwise
|
||||
"""
|
||||
if username is None or password is None:
|
||||
return False # invalid data supplied
|
||||
user = self.get_user(username)
|
||||
return user is not None and user.check_credentials(password, self.salt)
|
||||
|
||||
def get_user(self, username: str) -> Optional[User]:
|
||||
"""
|
||||
retrieve user from in-memory mapping
|
||||
:param username: username
|
||||
:return: user descriptor if username is known and None otherwise
|
||||
"""
|
||||
normalized_user = username.lower()
|
||||
return self._users.get(normalized_user)
|
||||
|
||||
def known_username(self, username: str) -> bool:
|
||||
"""
|
||||
check if user is known
|
||||
:param username: username
|
||||
:return: True in case if user is known and can be authorized and False otherwise
|
||||
"""
|
||||
return self.get_user(username) is not None
|
||||
|
||||
def verify_access(self, username: str, required: UserAccess, context: Optional[str]) -> bool:
|
||||
"""
|
||||
validate if user has access to requested resource
|
||||
:param username: username
|
||||
:param required: required access level
|
||||
:param context: URI request path
|
||||
:return: True in case if user is allowed to do this request and False otherwise
|
||||
"""
|
||||
del context
|
||||
user = self.get_user(username)
|
||||
return user is not None and user.verify_access(required)
|
@ -78,14 +78,14 @@ class Configuration(configparser.RawConfigParser):
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def section_name(section: str, architecture: str) -> str:
|
||||
def section_name(section: str, suffix: str) -> str:
|
||||
"""
|
||||
generate section name for architecture specific sections
|
||||
generate section name for sections which depends on context
|
||||
:param section: section name
|
||||
:param architecture: repository architecture
|
||||
:param suffix: session suffix, e.g. repository architecture
|
||||
:return: correct section name for repository specific section
|
||||
"""
|
||||
return f"{section}:{architecture}"
|
||||
return f"{section}:{suffix}"
|
||||
|
||||
def dump(self) -> Dict[str, Dict[str, str]]:
|
||||
"""
|
||||
@ -170,18 +170,27 @@ class Configuration(configparser.RawConfigParser):
|
||||
:param architecture: repository architecture
|
||||
"""
|
||||
for section in self.ARCHITECTURE_SPECIFIC_SECTIONS:
|
||||
if not self.has_section(section):
|
||||
self.add_section(section) # add section if not exists
|
||||
# get overrides
|
||||
specific = self.section_name(section, architecture)
|
||||
if self.has_section(specific):
|
||||
# if there is no such section it means that there is no overrides for this arch
|
||||
# but we anyway will have to delete sections for others archs
|
||||
for key, value in self[specific].items():
|
||||
self.set(section, key, value)
|
||||
self.set_option(section, key, value)
|
||||
# remove any arch specific section
|
||||
for foreign in self.sections():
|
||||
# we would like to use lambda filter here, but pylint is too dumb
|
||||
if not foreign.startswith(f"{section}:"):
|
||||
continue
|
||||
self.remove_section(foreign)
|
||||
|
||||
def set_option(self, section: str, option: str, value: Optional[str]) -> None:
|
||||
"""
|
||||
set option. Unlike default `configparser.RawConfigParser.set` it also creates section if it does not exist
|
||||
:param section: section name
|
||||
:param option: option name
|
||||
:param value: option value as string in parsable format
|
||||
"""
|
||||
if not self.has_section(section):
|
||||
self.add_section(section)
|
||||
self.set(section, option, value)
|
||||
|
@ -45,6 +45,19 @@ class DuplicateRun(Exception):
|
||||
Exception.__init__(self, "Another application instance is run")
|
||||
|
||||
|
||||
class DuplicateUser(Exception):
|
||||
"""
|
||||
exception which will be thrown in case if there are two users with different settings
|
||||
"""
|
||||
|
||||
def __init__(self, username: str) -> None:
|
||||
"""
|
||||
default constructor
|
||||
:param username: username with duplicates
|
||||
"""
|
||||
Exception.__init__(self, f"Found duplicate user with username {username}")
|
||||
|
||||
|
||||
class InitializeException(Exception):
|
||||
"""
|
||||
base service initialization exception
|
||||
|
@ -39,11 +39,12 @@ class Client:
|
||||
:param configuration: configuration instance
|
||||
:return: client according to current settings
|
||||
"""
|
||||
address = configuration.get("web", "address", fallback=None)
|
||||
host = configuration.get("web", "host", fallback=None)
|
||||
port = configuration.getint("web", "port", fallback=None)
|
||||
if host is not None and port is not None:
|
||||
if address or (host and port):
|
||||
from ahriman.core.status.web_client import WebClient
|
||||
return WebClient(host, port)
|
||||
return WebClient(configuration)
|
||||
return cls()
|
||||
|
||||
def add(self, package: Package, status: BuildStatusEnum) -> None:
|
||||
|
@ -22,37 +22,92 @@ import requests
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.status.client import Client
|
||||
from ahriman.core.util import exception_response_text
|
||||
from ahriman.models.build_status import BuildStatusEnum, BuildStatus
|
||||
from ahriman.models.internal_status import InternalStatus
|
||||
from ahriman.models.package import Package
|
||||
from ahriman.models.user import User
|
||||
|
||||
|
||||
class WebClient(Client):
|
||||
"""
|
||||
build status reporter web client
|
||||
:ivar host: host of web service
|
||||
:ivar address: address of the web service
|
||||
:ivar logger: class logger
|
||||
:ivar port: port of web service
|
||||
:ivar user: web service user descriptor
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, port: int) -> None:
|
||||
def __init__(self, configuration: Configuration) -> None:
|
||||
"""
|
||||
default constructor
|
||||
:param host: host of web service
|
||||
:param port: port of web service
|
||||
:param configuration: configuration instance
|
||||
"""
|
||||
self.logger = logging.getLogger("http")
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.address = self.parse_address(configuration)
|
||||
self.user = User.from_option(
|
||||
configuration.get("web", "username", fallback=None),
|
||||
configuration.get("web", "password", fallback=None))
|
||||
|
||||
self.__session = requests.session()
|
||||
self._login()
|
||||
|
||||
@property
|
||||
def _ahriman_url(self) -> str:
|
||||
"""
|
||||
url generator
|
||||
:return: full url for web service for ahriman service itself
|
||||
"""
|
||||
return f"http://{self.host}:{self.port}/api/v1/ahriman"
|
||||
return f"{self.address}/api/v1/ahriman"
|
||||
|
||||
@property
|
||||
def _login_url(self) -> str:
|
||||
"""
|
||||
:return: full url for web service to login
|
||||
"""
|
||||
return f"{self.address}/login"
|
||||
|
||||
@property
|
||||
def _status_url(self) -> str:
|
||||
"""
|
||||
:return: full url for web service for status
|
||||
"""
|
||||
return f"{self.address}/api/v1/status"
|
||||
|
||||
@staticmethod
|
||||
def parse_address(configuration: Configuration) -> str:
|
||||
"""
|
||||
parse address from configuration
|
||||
:param configuration: configuration instance
|
||||
:return: valid http address
|
||||
"""
|
||||
address = configuration.get("web", "address", fallback=None)
|
||||
if not address:
|
||||
# build address from host and port directly
|
||||
host = configuration.get("web", "host")
|
||||
port = configuration.getint("web", "port")
|
||||
address = f"http://{host}:{port}"
|
||||
return address
|
||||
|
||||
def _login(self) -> None:
|
||||
"""
|
||||
process login to the service
|
||||
"""
|
||||
if self.user is None:
|
||||
return # no auth configured
|
||||
|
||||
payload = {
|
||||
"username": self.user.username,
|
||||
"password": self.user.password
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.__session.post(self._login_url, json=payload)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
self.logger.exception("could not login as %s: %s", self.user, exception_response_text(e))
|
||||
except Exception:
|
||||
self.logger.exception("could not login as %s", self.user)
|
||||
|
||||
def _package_url(self, base: str = "") -> str:
|
||||
"""
|
||||
@ -60,14 +115,7 @@ class WebClient(Client):
|
||||
:param base: package base to generate url
|
||||
:return: full url of web service for specific package base
|
||||
"""
|
||||
return f"http://{self.host}:{self.port}/api/v1/packages/{base}"
|
||||
|
||||
def _status_url(self) -> str:
|
||||
"""
|
||||
url generator
|
||||
:return: full url for web service for status
|
||||
"""
|
||||
return f"http://{self.host}:{self.port}/api/v1/status"
|
||||
return f"{self.address}/api/v1/packages/{base}"
|
||||
|
||||
def add(self, package: Package, status: BuildStatusEnum) -> None:
|
||||
"""
|
||||
@ -81,7 +129,7 @@ class WebClient(Client):
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(self._package_url(package.base), json=payload)
|
||||
response = self.__session.post(self._package_url(package.base), json=payload)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
self.logger.exception("could not add %s: %s", package.base, exception_response_text(e))
|
||||
@ -95,7 +143,7 @@ class WebClient(Client):
|
||||
:return: list of current package description and status if it has been found
|
||||
"""
|
||||
try:
|
||||
response = requests.get(self._package_url(base or ""))
|
||||
response = self.__session.get(self._package_url(base or ""))
|
||||
response.raise_for_status()
|
||||
|
||||
status_json = response.json()
|
||||
@ -115,7 +163,7 @@ class WebClient(Client):
|
||||
:return: current internal (web) service status
|
||||
"""
|
||||
try:
|
||||
response = requests.get(self._status_url())
|
||||
response = self.__session.get(self._status_url)
|
||||
response.raise_for_status()
|
||||
|
||||
status_json = response.json()
|
||||
@ -132,7 +180,7 @@ class WebClient(Client):
|
||||
:return: current ahriman status
|
||||
"""
|
||||
try:
|
||||
response = requests.get(self._ahriman_url())
|
||||
response = self.__session.get(self._ahriman_url)
|
||||
response.raise_for_status()
|
||||
|
||||
status_json = response.json()
|
||||
@ -149,7 +197,7 @@ class WebClient(Client):
|
||||
:param base: basename to remove
|
||||
"""
|
||||
try:
|
||||
response = requests.delete(self._package_url(base))
|
||||
response = self.__session.delete(self._package_url(base))
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
self.logger.exception("could not delete %s: %s", base, exception_response_text(e))
|
||||
@ -165,7 +213,7 @@ class WebClient(Client):
|
||||
payload = {"status": status.value}
|
||||
|
||||
try:
|
||||
response = requests.post(self._package_url(base), json=payload)
|
||||
response = self.__session.post(self._package_url(base), json=payload)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
self.logger.exception("could not update %s: %s", base, exception_response_text(e))
|
||||
@ -180,7 +228,7 @@ class WebClient(Client):
|
||||
payload = {"status": status.value}
|
||||
|
||||
try:
|
||||
response = requests.post(self._ahriman_url(), json=payload)
|
||||
response = self.__session.post(self._ahriman_url, json=payload)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
self.logger.exception("could not update service status: %s", exception_response_text(e))
|
||||
|
58
src/ahriman/models/auth_settings.py
Normal file
58
src/ahriman/models/auth_settings.py
Normal file
@ -0,0 +1,58 @@
|
||||
#
|
||||
# Copyright (c) 2021 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum, auto
|
||||
from typing import Type
|
||||
|
||||
from ahriman.core.exceptions import InvalidOption
|
||||
|
||||
|
||||
class AuthSettings(Enum):
|
||||
"""
|
||||
web authorization type
|
||||
:cvar Disabled: authorization is disabled
|
||||
:cvar Configuration: configuration based authorization
|
||||
"""
|
||||
|
||||
Disabled = auto()
|
||||
Configuration = auto()
|
||||
|
||||
@classmethod
|
||||
def from_option(cls: Type[AuthSettings], value: str) -> AuthSettings:
|
||||
"""
|
||||
construct value from configuration
|
||||
:param value: configuration value
|
||||
:return: parsed value
|
||||
"""
|
||||
if value.lower() in ("disabled", "no"):
|
||||
return cls.Disabled
|
||||
if value.lower() in ("configuration", "mapping"):
|
||||
return cls.Configuration
|
||||
raise InvalidOption(value)
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
"""
|
||||
:return: False in case if authorization is disabled and True otherwise
|
||||
"""
|
||||
if self == AuthSettings.Disabled:
|
||||
return False
|
||||
return True
|
101
src/ahriman/models/user.py
Normal file
101
src/ahriman/models/user.py
Normal file
@ -0,0 +1,101 @@
|
||||
#
|
||||
# Copyright (c) 2021 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Type
|
||||
from passlib.pwd import genword as generate_password # type: ignore
|
||||
from passlib.handlers.sha2_crypt import sha512_crypt # type: ignore
|
||||
|
||||
from ahriman.models.user_access import UserAccess
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""
|
||||
authorized web user model
|
||||
:ivar username: username
|
||||
:ivar password: hashed user password with salt
|
||||
:ivar access: user role
|
||||
"""
|
||||
username: str
|
||||
password: str
|
||||
access: UserAccess
|
||||
|
||||
_HASHER = sha512_crypt
|
||||
|
||||
@classmethod
|
||||
def from_option(cls: Type[User], username: Optional[str], password: Optional[str]) -> Optional[User]:
|
||||
"""
|
||||
build user descriptor from configuration options
|
||||
:param username: username
|
||||
:param password: password as string
|
||||
:return: generated user descriptor if all options are supplied and None otherwise
|
||||
"""
|
||||
if username is None or password is None:
|
||||
return None
|
||||
return cls(username, password, UserAccess.Status)
|
||||
|
||||
@staticmethod
|
||||
def generate_password(length: int) -> str:
|
||||
"""
|
||||
generate password with specified length
|
||||
:param length: password length
|
||||
:return: random string which contains letters and numbers
|
||||
"""
|
||||
password: str = generate_password(length=length)
|
||||
return password
|
||||
|
||||
def check_credentials(self, password: str, salt: str) -> bool:
|
||||
"""
|
||||
validate user password
|
||||
:param password: entered password
|
||||
:param salt: salt for hashed password
|
||||
:return: True in case if password matches, False otherwise
|
||||
"""
|
||||
verified: bool = self._HASHER.verify(password + salt, self.password)
|
||||
return verified
|
||||
|
||||
def hash_password(self, password: str, salt: str) -> str:
|
||||
"""
|
||||
generate hashed password from plain text
|
||||
:param password: entered password
|
||||
:param salt: salt for hashed password
|
||||
:return: hashed string to store in configuration
|
||||
"""
|
||||
password_hash: str = self._HASHER.hash(password + salt)
|
||||
return password_hash
|
||||
|
||||
def verify_access(self, required: UserAccess) -> bool:
|
||||
"""
|
||||
validate if user has access to requested resource
|
||||
:param required: required access level
|
||||
:return: True in case if user is allowed to do this request and False otherwise
|
||||
"""
|
||||
if self.access == UserAccess.Write:
|
||||
return True # everything is allowed
|
||||
return self.access == required
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
generate string representation of object
|
||||
:return: unique string representation
|
||||
"""
|
||||
return f"User(username={self.username}, access={self.access})"
|
33
src/ahriman/models/user_access.py
Normal file
33
src/ahriman/models/user_access.py
Normal file
@ -0,0 +1,33 @@
|
||||
#
|
||||
# Copyright (c) 2021 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class UserAccess(Enum):
|
||||
"""
|
||||
web user access enumeration
|
||||
:cvar Read: user can read status page
|
||||
:cvar Write: user can modify task and package list
|
||||
:cvar Status: user can update statuses via API
|
||||
"""
|
||||
|
||||
Read = "read"
|
||||
Write = "write"
|
||||
Status = "status"
|
@ -17,3 +17,10 @@
|
||||
# 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 Request
|
||||
from aiohttp.web_response import StreamResponse
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
|
||||
HandlerType = Callable[[Request], Awaitable[StreamResponse]]
|
||||
MiddlewareType = Callable[[Request, HandlerType], Awaitable[StreamResponse]]
|
||||
|
109
src/ahriman/web/middlewares/auth_handler.py
Normal file
109
src/ahriman/web/middlewares/auth_handler.py
Normal file
@ -0,0 +1,109 @@
|
||||
#
|
||||
# Copyright (c) 2021 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
import aiohttp_security # type: ignore
|
||||
import base64
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import middleware, Request
|
||||
from aiohttp.web_response import StreamResponse
|
||||
from aiohttp_session import setup as setup_session # type: ignore
|
||||
from aiohttp_session.cookie_storage import EncryptedCookieStorage # type: ignore
|
||||
from cryptography import fernet
|
||||
from typing import Optional
|
||||
|
||||
from ahriman.core.auth.auth import Auth
|
||||
from ahriman.models.user_access import UserAccess
|
||||
from ahriman.web.middlewares import HandlerType, MiddlewareType
|
||||
|
||||
|
||||
class AuthorizationPolicy(aiohttp_security.AbstractAuthorizationPolicy): # type: ignore
|
||||
"""
|
||||
authorization policy implementation
|
||||
:ivar validator: validator instance
|
||||
"""
|
||||
|
||||
def __init__(self, validator: Auth) -> None:
|
||||
"""
|
||||
default constructor
|
||||
:param validator: authorization module instance
|
||||
"""
|
||||
self.validator = validator
|
||||
|
||||
async def authorized_userid(self, identity: str) -> Optional[str]:
|
||||
"""
|
||||
retrieve authorized username
|
||||
:param identity: username
|
||||
:return: user identity (username) in case if user exists and None otherwise
|
||||
"""
|
||||
return identity if self.validator.known_username(identity) else None
|
||||
|
||||
async def permits(self, identity: str, permission: UserAccess, context: Optional[str] = None) -> bool:
|
||||
"""
|
||||
check user permissions
|
||||
:param identity: username
|
||||
:param permission: requested permission level
|
||||
:param context: URI request path
|
||||
:return: True in case if user is allowed to perform this request and False otherwise
|
||||
"""
|
||||
return self.validator.verify_access(identity, permission, context)
|
||||
|
||||
|
||||
def auth_handler(validator: Auth) -> MiddlewareType:
|
||||
"""
|
||||
authorization and authentication middleware
|
||||
:param validator: authorization module instance
|
||||
:return: built middleware
|
||||
"""
|
||||
@middleware
|
||||
async def handle(request: Request, handler: HandlerType) -> StreamResponse:
|
||||
if request.path.startswith("/api"):
|
||||
permission = UserAccess.Status
|
||||
elif request.method in ("GET", "HEAD", "OPTIONS"):
|
||||
permission = UserAccess.Read
|
||||
else:
|
||||
permission = UserAccess.Write
|
||||
|
||||
if not validator.is_safe_request(request.path):
|
||||
await aiohttp_security.check_permission(request, permission, request.path)
|
||||
|
||||
return await handler(request)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def setup_auth(application: web.Application, validator: Auth) -> web.Application:
|
||||
"""
|
||||
setup authorization policies for the application
|
||||
:param application: web application instance
|
||||
:param validator: authorization module instance
|
||||
:return: configured web application
|
||||
"""
|
||||
fernet_key = fernet.Fernet.generate_key()
|
||||
secret_key = base64.urlsafe_b64decode(fernet_key)
|
||||
storage = EncryptedCookieStorage(secret_key, cookie_name='API_SESSION')
|
||||
setup_session(application, storage)
|
||||
|
||||
authorization_policy = AuthorizationPolicy(validator)
|
||||
identity_policy = aiohttp_security.SessionIdentityPolicy()
|
||||
|
||||
aiohttp_security.setup(application, identity_policy, authorization_policy)
|
||||
application.middlewares.append(auth_handler(validator))
|
||||
|
||||
return application
|
@ -21,13 +21,11 @@ from aiohttp.web import middleware, Request
|
||||
from aiohttp.web_exceptions import HTTPClientError
|
||||
from aiohttp.web_response import StreamResponse
|
||||
from logging import Logger
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from ahriman.web.middlewares import HandlerType, MiddlewareType
|
||||
|
||||
|
||||
HandlerType = Callable[[Request], Awaitable[StreamResponse]]
|
||||
|
||||
|
||||
def exception_handler(logger: Logger) -> Callable[[Request, HandlerType], Awaitable[StreamResponse]]:
|
||||
def exception_handler(logger: Logger) -> MiddlewareType:
|
||||
"""
|
||||
exception handler middleware. Just log any exception (except for client ones)
|
||||
:param logger: class logger
|
||||
|
@ -21,6 +21,8 @@ from aiohttp.web import Application
|
||||
|
||||
from ahriman.web.views.ahriman import AhrimanView
|
||||
from ahriman.web.views.index import IndexView
|
||||
from ahriman.web.views.login import LoginView
|
||||
from ahriman.web.views.logout import LogoutView
|
||||
from ahriman.web.views.package import PackageView
|
||||
from ahriman.web.views.packages import PackagesView
|
||||
from ahriman.web.views.status import StatusView
|
||||
@ -35,6 +37,9 @@ def setup_routes(application: Application) -> None:
|
||||
GET / get build status page
|
||||
GET /index.html same as above
|
||||
|
||||
POST /login login to service
|
||||
POST /logout logout from service
|
||||
|
||||
GET /api/v1/ahriman get current service status
|
||||
POST /api/v1/ahriman update service status
|
||||
|
||||
@ -52,6 +57,9 @@ def setup_routes(application: Application) -> None:
|
||||
application.router.add_get("/", IndexView)
|
||||
application.router.add_get("/index.html", IndexView)
|
||||
|
||||
application.router.add_post("/login", LoginView)
|
||||
application.router.add_post("/logout", LogoutView)
|
||||
|
||||
application.router.add_get("/api/v1/ahriman", AhrimanView)
|
||||
application.router.add_post("/api/v1/ahriman", AhrimanView)
|
||||
|
||||
|
@ -46,7 +46,7 @@ class AhrimanView(BaseView):
|
||||
|
||||
:return: 204 on success
|
||||
"""
|
||||
data = await self.request.json()
|
||||
data = await self.extract_data()
|
||||
|
||||
try:
|
||||
status = BuildStatusEnum(data["status"])
|
||||
|
@ -18,7 +18,9 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
from aiohttp.web import View
|
||||
from typing import Any, Dict
|
||||
|
||||
from ahriman.core.auth.auth import Auth
|
||||
from ahriman.core.status.watcher import Watcher
|
||||
|
||||
|
||||
@ -34,3 +36,22 @@ class BaseView(View):
|
||||
"""
|
||||
watcher: Watcher = self.request.app["watcher"]
|
||||
return watcher
|
||||
|
||||
@property
|
||||
def validator(self) -> Auth:
|
||||
"""
|
||||
:return: authorization service instance
|
||||
"""
|
||||
validator: Auth = self.request.app["validator"]
|
||||
return validator
|
||||
|
||||
async def extract_data(self) -> Dict[str, Any]:
|
||||
"""
|
||||
extract json data from either json or form data
|
||||
:return: raw json object or form data converted to json
|
||||
"""
|
||||
try:
|
||||
json: Dict[str, Any] = await self.request.json()
|
||||
return json
|
||||
except ValueError:
|
||||
return dict(await self.request.post())
|
||||
|
@ -22,6 +22,7 @@ import aiohttp_jinja2
|
||||
from typing import Any, Dict
|
||||
|
||||
from ahriman import version
|
||||
from ahriman.core.auth.helpers import authorized_userid
|
||||
from ahriman.core.util import pretty_datetime
|
||||
from ahriman.web.views.base import BaseView
|
||||
|
||||
@ -33,6 +34,9 @@ class IndexView(BaseView):
|
||||
It uses jinja2 templates for report generation, the following variables are allowed:
|
||||
|
||||
architecture - repository architecture, string, required
|
||||
authorized - alias for `not auth_enabled or auth_username is not None`
|
||||
auth_enabled - whether authorization is enabled by configuration or not, boolean, required
|
||||
auth_username - authorized user id if any, string. None means not authorized
|
||||
packages - sorted list of packages properties, required
|
||||
* base, string
|
||||
* depends, sorted list of strings
|
||||
@ -77,8 +81,15 @@ class IndexView(BaseView):
|
||||
"timestamp": pretty_datetime(self.service.status.timestamp)
|
||||
}
|
||||
|
||||
# auth block
|
||||
auth_username = await authorized_userid(self.request)
|
||||
authorized = not self.validator.enabled or auth_username is not None
|
||||
|
||||
return {
|
||||
"architecture": self.service.architecture,
|
||||
"authorized": authorized,
|
||||
"auth_enabled": self.validator.enabled,
|
||||
"auth_username": auth_username,
|
||||
"packages": packages,
|
||||
"repository": self.service.repository.name,
|
||||
"service": service,
|
||||
|
51
src/ahriman/web/views/login.py
Normal file
51
src/ahriman/web/views/login.py
Normal file
@ -0,0 +1,51 @@
|
||||
#
|
||||
# Copyright (c) 2021 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
from aiohttp.web import HTTPFound, HTTPUnauthorized, Response
|
||||
|
||||
from ahriman.core.auth.helpers import remember
|
||||
from ahriman.web.views.base import BaseView
|
||||
|
||||
|
||||
class LoginView(BaseView):
|
||||
"""
|
||||
login endpoint view
|
||||
"""
|
||||
|
||||
async def post(self) -> Response:
|
||||
"""
|
||||
login user to service
|
||||
|
||||
either JSON body or form data must be supplied the following fields are required:
|
||||
{
|
||||
"username": "username" # username to use for login
|
||||
"password": "pa55w0rd" # password to use for login
|
||||
}
|
||||
|
||||
:return: redirect to main page
|
||||
"""
|
||||
data = await self.extract_data()
|
||||
username = data.get("username")
|
||||
|
||||
response = HTTPFound("/")
|
||||
if self.validator.check_credentials(username, data.get("password")):
|
||||
await remember(self.request, response, username)
|
||||
return response
|
||||
|
||||
raise HTTPUnauthorized()
|
41
src/ahriman/web/views/logout.py
Normal file
41
src/ahriman/web/views/logout.py
Normal file
@ -0,0 +1,41 @@
|
||||
#
|
||||
# Copyright (c) 2021 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
from aiohttp.web import HTTPFound, Response
|
||||
|
||||
from ahriman.core.auth.helpers import check_authorized, forget
|
||||
from ahriman.web.views.base import BaseView
|
||||
|
||||
|
||||
class LogoutView(BaseView):
|
||||
"""
|
||||
logout endpoint view
|
||||
"""
|
||||
|
||||
async def post(self) -> Response:
|
||||
"""
|
||||
logout user from the service. No parameters supported here
|
||||
:return: redirect to main page
|
||||
"""
|
||||
await check_authorized(self.request)
|
||||
|
||||
response = HTTPFound("/")
|
||||
await forget(self.request, response)
|
||||
|
||||
return response
|
@ -74,7 +74,7 @@ class PackageView(BaseView):
|
||||
:return: 204 on success
|
||||
"""
|
||||
base = self.request.match_info["package"]
|
||||
data = await self.request.json()
|
||||
data = await self.extract_data()
|
||||
|
||||
try:
|
||||
package = Package.from_json(data["package"]) if "package" in data else None
|
||||
|
@ -23,6 +23,7 @@ import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ahriman.core.auth.auth import Auth
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.exceptions import InitializeException
|
||||
from ahriman.core.status.watcher import Watcher
|
||||
@ -92,4 +93,10 @@ def setup_service(architecture: str, configuration: Configuration) -> web.Applic
|
||||
application.logger.info("setup watcher")
|
||||
application["watcher"] = Watcher(architecture, configuration)
|
||||
|
||||
application.logger.info("setup authorization")
|
||||
validator = application["validator"] = Auth.load(configuration)
|
||||
if validator.enabled:
|
||||
from ahriman.web.middlewares.auth_handler import setup_auth
|
||||
setup_auth(application, validator)
|
||||
|
||||
return application
|
||||
|
Reference in New Issue
Block a user