* make auth method asyncs

* oauth2 demo support

* full coverage

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

View File

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