mirror of
https://github.com/arcan1s/ahriman.git
synced 2025-04-24 15:27:17 +00:00
oauth2 demo support
This commit is contained in:
parent
c4e7f63d7c
commit
b6950ba554
@ -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
|
||||
|
@ -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'
|
||||
|
@ -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 %}
|
||||
|
||||
|
1
setup.py
1
setup.py
@ -106,6 +106,7 @@ setup(
|
||||
"Jinja2",
|
||||
"aiohttp",
|
||||
"aiohttp_jinja2",
|
||||
"aioauth-client",
|
||||
"aiohttp_session",
|
||||
"aiohttp_security",
|
||||
"cryptography",
|
||||
|
@ -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
|
||||
|
106
src/ahriman/core/auth/oauth.py
Normal file
106
src/ahriman/core/auth/oauth.py
Normal 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
|
@ -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):
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user