From b6950ba5549fbcfc5dc32a16345bf73e7893992e Mon Sep 17 00:00:00 2001 From: Evgeniy Alekseev Date: Sun, 12 Sep 2021 13:27:05 +0300 Subject: [PATCH] oauth2 demo support --- docs/configuration.md | 10 +- package/archlinux/PKGBUILD | 1 + package/share/ahriman/build-status.jinja2 | 16 ++-- setup.py | 1 + src/ahriman/core/auth/auth.py | 14 +++ src/ahriman/core/auth/oauth.py | 106 ++++++++++++++++++++++ src/ahriman/core/exceptions.py | 5 +- src/ahriman/core/util.py | 2 +- src/ahriman/models/counters.py | 1 + src/ahriman/models/internal_status.py | 1 + src/ahriman/models/user.py | 16 +++- src/ahriman/web/routes.py | 2 + src/ahriman/web/views/index.py | 23 +++-- src/ahriman/web/views/user/login.py | 29 +++++- src/ahriman/web/web.py | 5 +- 15 files changed, 204 insertions(+), 28 deletions(-) create mode 100644 src/ahriman/core/auth/oauth.py diff --git a/docs/configuration.md b/docs/configuration.md index 2fb86987..c5a17e3b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -20,13 +20,18 @@ libalpm and AUR related configuration. ## `auth` group -Base authorization settings. +Base authorization settings. `OAuth2` 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 `oauth2` is used. +* `client_secret` - OAuth2 application client secret key, string, required in case if `oauth2` 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 `oauth2` is used. +* `oauth_redirect_uri` - full URI for OAuth2 redirect, must point to `/user-api/v1/login`, e.g. `https://example.com/user-api/v1/login`, string, required in case if `oauth2` 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 `oauth2` is used. * `salt` - password hash salt, string, required in case if authorization enabled (automatically generated by `create-user` subcommand). ## `auth:*` groups @@ -35,6 +40,7 @@ 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: +* `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 diff --git a/package/archlinux/PKGBUILD b/package/archlinux/PKGBUILD index fac0d2f5..804726ae 100644 --- a/package/archlinux/PKGBUILD +++ b/package/archlinux/PKGBUILD @@ -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' diff --git a/package/share/ahriman/build-status.jinja2 b/package/share/ahriman/build-status.jinja2 index d1cc2a7a..a92fedab 100644 --- a/package/share/ahriman/build-status.jinja2 +++ b/package/share/ahriman/build-status.jinja2 @@ -14,7 +14,7 @@

ahriman - {% if authorized %} + {% if auth.authorized %} {{ version }} {{ repository }} {{ architecture }} @@ -25,7 +25,7 @@
- {% if not auth_enabled or auth_username is not none %} + {% if not auth.enabled or auth.username is not none %} @@ -70,7 +70,7 @@ - {% if authorized %} + {% if auth.authorized %} {% for package in packages %} @@ -100,19 +100,19 @@
  • report a bug
  • - {% if auth_enabled %} - {% if auth_username is none %} - + {% if auth.enabled %} + {% if auth.username is none %} + {{ auth.control|safe }} {% else %}
    - +
    {% endif %} {% endif %}
    - {% if auth_enabled %} + {% if auth.enabled %} {% include "build-status/login-modal.jinja2" %} {% endif %} diff --git a/setup.py b/setup.py index 8e0eb5f1..e49160fc 100644 --- a/setup.py +++ b/setup.py @@ -106,6 +106,7 @@ setup( "Jinja2", "aiohttp", "aiohttp_jinja2", + "aioauth-client", "aiohttp_session", "aiohttp_security", "cryptography", diff --git a/src/ahriman/core/auth/auth.py b/src/ahriman/core/auth/auth.py index 3c99751a..07c994ea 100644 --- a/src/ahriman/core/auth/auth.py +++ b/src/ahriman/core/auth/auth.py @@ -55,6 +55,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 authorization 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 """""" + @classmethod def load(cls: Type[Auth], configuration: Configuration) -> Auth: """ @@ -66,6 +77,9 @@ class Auth: if provider == AuthSettings.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) @staticmethod diff --git a/src/ahriman/core/auth/oauth.py b/src/ahriman/core/auth/oauth.py new file mode 100644 index 00000000..059759e0 --- /dev/null +++ b/src/ahriman/core/auth/oauth.py @@ -0,0 +1,106 @@ +# +# 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 . +# +import aioauth_client # type: ignore + +from typing import 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") + self.redirect_uri = configuration.get("auth", "oauth_redirect_uri") + 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 """login""" + + @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) -> str: + """ + extract OAuth username from remote + :param code: authorization code provided by external service + :return: username as is in OAuth provider + """ + client = self.get_client() + access_token, _ = await client.get_access_token(code, redirect_uri=self.redirect_uri) + client.access_token = access_token + + user, _ = await client.user_info() + username: str = user.email + return username diff --git a/src/ahriman/core/exceptions.py b/src/ahriman/core/exceptions.py index 10a09989..a2daf49d 100644 --- a/src/ahriman/core/exceptions.py +++ b/src/ahriman/core/exceptions.py @@ -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): diff --git a/src/ahriman/core/util.py b/src/ahriman/core/util.py index db3f460a..438aac58 100644 --- a/src/ahriman/core/util.py +++ b/src/ahriman/core/util.py @@ -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: diff --git a/src/ahriman/models/counters.py b/src/ahriman/models/counters.py index c1ffafbd..13e7e344 100644 --- a/src/ahriman/models/counters.py +++ b/src/ahriman/models/counters.py @@ -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 diff --git a/src/ahriman/models/internal_status.py b/src/ahriman/models/internal_status.py index 8956e37c..0d452788 100644 --- a/src/ahriman/models/internal_status.py +++ b/src/ahriman/models/internal_status.py @@ -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 diff --git a/src/ahriman/models/user.py b/src/ahriman/models/user.py index ef0c58bb..977fd0bf 100644 --- a/src/ahriman/models/user.py +++ b/src/ahriman/models/user.py @@ -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 diff --git a/src/ahriman/web/routes.py b/src/ahriman/web/routes.py index 980d2d1a..64062bc4 100644 --- a/src/ahriman/web/routes.py +++ b/src/ahriman/web/routes.py @@ -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) diff --git a/src/ahriman/web/views/index.py b/src/ahriman/web/views/index.py index 341ab8bf..a4291ce1 100644 --- a/src/ahriman/web/views/index.py +++ b/src/ahriman/web/views/index.py @@ -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 + * authorized - 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 - authorized username if any, string, null means not authorized 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 = { + "authorized": 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, diff --git a/src/ahriman/web/views/user/login.py b/src/ahriman/web/views/user/login.py index ad6ebffa..cb17c41f 100644 --- a/src/ahriman/web/views/user/login.py +++ b/src/ahriman/web/views/user/login.py @@ -17,7 +17,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -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): + raise HTTPMethodNotAllowed(self.request.method, ["POST"]) + + if code is None: + 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 diff --git a/src/ahriman/web/web.py b/src/ahriman/web/web.py index 94e56c5f..d97f65e5 100644 --- a/src/ahriman/web/web.py +++ b/src/ahriman/web/web.py @@ -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: