oauth2 demo support

This commit is contained in:
Evgenii Alekseev 2021-09-12 13:27:05 +03:00
parent c4e7f63d7c
commit b6950ba554
15 changed files with 204 additions and 28 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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 """<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) -> 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

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

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

@ -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
* 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,

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

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: