mirror of
https://github.com/arcan1s/ahriman.git
synced 2025-08-31 22:09:56 +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:
@ -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