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:
2021-09-02 23:36:00 +03:00
committed by GitHub
parent 3922c55464
commit e63cb509f2
63 changed files with 2200 additions and 184 deletions

View File

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

View 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

View File

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

View File

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

View File

@ -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"])

View File

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

View File

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

View 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()

View 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

View File

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

View File

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