diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index a8a36409..00000000 --- a/.coveragerc +++ /dev/null @@ -1,5 +0,0 @@ -[report] -omit = */maildir/*, */redis/*, */main.py -exclude_lines = - pragma: no cover - ^\s*...\s*$ diff --git a/README.md b/README.md index ecc7d3c7..f36446b2 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ metadata. It requires [aioredis][9] and will not appear in the plugins list without it. ``` -$ pip install aioredis msgpack +$ pip install 'pymap[redis]' $ pymap redis --help ``` @@ -164,7 +164,7 @@ instead of a redis hash with the `--users-json` command-line argument. Try out the redis plugin: ``` -$ pymap --port 1143 --debug redis redis://localhost +$ pymap --port 1143 --debug redis ``` Once started, check out the dict plugin example above to connect and see it in @@ -175,12 +175,12 @@ action. The `pymap-admin` tool is installed as an optional dependency: ``` -$ pip install pymap[admin] +$ pip install 'pymap[admin]' ``` -Once installed, subsequent restarts of the pymap server will listen on port -9090 by default for incoming requests from the admin tool. See the -[pymap-admin][10] page for more information, and check out the help: +Once installed, subsequent restarts of the pymap server will listen on a UNIX +socket for incoming requests from the admin tool. See the [pymap-admin][10] +page for more information, and check out the help: ``` $ pymap-admin --help diff --git a/doc/source/pymap.admin.rst b/doc/source/pymap.admin.rst index ba7406ce..5caf6cc8 100644 --- a/doc/source/pymap.admin.rst +++ b/doc/source/pymap.admin.rst @@ -5,6 +5,12 @@ .. automodule:: pymap.admin :members: +.. automodule:: pymap.admin.errors + :members: + +.. automodule:: pymap.admin.token + :members: + ``pymap.admin.handlers`` ---------------------------- diff --git a/doc/source/pymap.interfaces.rst b/doc/source/pymap.interfaces.rst index 9d51e086..55b2eb43 100644 --- a/doc/source/pymap.interfaces.rst +++ b/doc/source/pymap.interfaces.rst @@ -7,39 +7,33 @@ .. automodule:: pymap.interfaces.backend :members: - :special-members: __call__ ``pymap.interfaces.message`` ---------------------------- .. automodule:: pymap.interfaces.message :members: - :special-members: __call__ ``pymap.interfaces.mailbox`` ---------------------------- .. automodule:: pymap.interfaces.mailbox :members: - :special-members: __call__ ``pymap.interfaces.session`` ---------------------------- .. automodule:: pymap.interfaces.session :members: - :special-members: __call__ -``pymap.interfaces.users`` +``pymap.interfaces.login`` -------------------------- -.. automodule:: pymap.interfaces.users +.. automodule:: pymap.interfaces.login :members: - :special-members: __call__ ``pymap.interfaces.filter`` --------------------------- .. automodule:: pymap.interfaces.filter :members: - :special-members: __call__ diff --git a/pymap/admin/__init__.py b/pymap/admin/__init__.py index 62198147..a275bd79 100644 --- a/pymap/admin/__init__.py +++ b/pymap/admin/__init__.py @@ -2,17 +2,23 @@ from __future__ import annotations import asyncio -from argparse import ArgumentParser +import sys +from argparse import ArgumentParser, SUPPRESS from asyncio import CancelledError +from datetime import datetime, timedelta, timezone +from secrets import token_bytes from ssl import SSLContext -from typing import Optional, Tuple, Sequence, Awaitable -from urllib.parse import urlparse +from typing import Optional, Sequence, Awaitable +from grpclib.events import listen, RecvRequest from grpclib.server import Server from pymap.interfaces.backend import BackendInterface, ServiceInterface +from pymapadmin import is_compatible, __version__ as server_version from pymapadmin.local import get_admin_socket +from .errors import get_incompatible_version_error from .handlers import handlers +from .token import get_admin_token __all__ = ['AdminService'] @@ -34,59 +40,69 @@ class AdminService(ServiceInterface): # pragma: no cover """ + _admin_token: Optional[bytes] = None + @classmethod def add_arguments(cls, parser: ArgumentParser) -> None: group = parser.add_argument_group('admin service') - group.add_argument('--admin-private', metavar='HOST', - action='append', default=[], - help='private host:port to listen on') - group.add_argument('--admin-public', metavar='HOST', - action='append', default=[], - help='public host:port to listen on') + group.add_argument('--admin-host', action='append', metavar='IFACE', + help='the network interface to listen on') + group.add_argument('--admin-port', metavar='NUM', + help='the port or service name to listen on') + group.add_argument('--no-admin-token', action='store_true', + help='disable the admin token') + group.add_argument('--admin-token-duration', metavar='SEC', type=int, + help=SUPPRESS) + + def _init_admin_token(self) -> None: + if not self.config.args.no_admin_token: + expiration: Optional[datetime] = None + if self.config.args.admin_token_duration: + duration_sec: int = self.config.args.admin_token_duration + duration = timedelta(seconds=duration_sec) + expiration = datetime.now(tz=timezone.utc) + duration + self._admin_token = token_bytes() + token = get_admin_token(self._admin_token, expiration) + print(f'PYMAP_ADMIN_TOKEN={token.serialize()}', file=sys.stderr) + + async def _check_version(self, event: RecvRequest) -> None: + client_version = event.metadata['client-version'] + if isinstance(client_version, bytes): + client_version = client_version.decode('ascii') + if not is_compatible(client_version, server_version): + raise get_incompatible_version_error( + client_version, server_version) async def start(self) -> Awaitable: + self._init_admin_token() backend = self.backend - private_hosts: Sequence[str] = self.config.args.admin_private - public_hosts: Sequence[str] = self.config.args.admin_public + host: Optional[str] = self.config.args.admin_host + port: Optional[int] = self.config.args.admin_port ssl = self.config.ssl_context servers = [await self._start_local(backend)] - servers += [await self._start(backend, False, host, None) - for host in private_hosts] - servers += [await self._start(backend, True, host, ssl) - for host in public_hosts] + if host or port: + servers += [await self._start(backend, host, port, ssl)] return asyncio.create_task(self._run(servers)) - @classmethod - def _new_server(cls, backend: BackendInterface, public: bool) -> Server: - return Server([handler(backend, public) for _, handler in handlers]) + def _new_server(self, backend: BackendInterface) -> Server: + server = Server([handler(backend, self._admin_token) + for _, handler in handlers]) + listen(server, RecvRequest, self._check_version) + return server - @classmethod - async def _start_local(cls, backend: BackendInterface) -> Server: - server = cls._new_server(backend, False) + async def _start_local(self, backend: BackendInterface) -> Server: + server = self._new_server(backend) path = get_admin_socket(mkdir=True) await server.start(path=path) return server - @classmethod - async def _start(cls, backend: BackendInterface, public: bool, - host: str, ssl: Optional[SSLContext]) -> Server: - server = cls._new_server(backend, public) - host, port = cls._parse(host) + async def _start(self, backend: BackendInterface, host: Optional[str], + port: Optional[int], ssl: Optional[SSLContext]) -> Server: + server = self._new_server(backend) await server.start(host=host, port=port, ssl=ssl, reuse_address=True) return server - @classmethod - def _parse(cls, host: str) -> Tuple[str, Optional[int]]: - parsed = urlparse(host) - if parsed.hostname: - return parsed.hostname, parsed.port - parsed = urlparse(f'//{host}') - if parsed.hostname: - return parsed.hostname, parsed.port - raise ValueError(host) - - @classmethod - async def _run(cls, servers: Sequence[Server]) -> None: + async def _run(self, servers: Sequence[Server]) -> None: try: for server in servers: await server.wait_closed() diff --git a/pymap/admin/errors.py b/pymap/admin/errors.py new file mode 100644 index 00000000..dd24adb7 --- /dev/null +++ b/pymap/admin/errors.py @@ -0,0 +1,42 @@ + +from __future__ import annotations + +from grpclib.const import Status +from grpclib.exceptions import GRPCError +from google.rpc.error_details_pb2 import ErrorInfo + +__all__ = ['get_unimplemented_error', 'get_incompatible_version_error'] + + +def get_unimplemented_error(*, domain: str = None, **metadata: str) \ + -> GRPCError: + """Build a :exc:`~grpclib.exceptions.GRPCError` exception for an + operation that is not implemented by the server. + + Args: + domain: The domain string to include in the error. + metadata: Additional metadata to include in the error. + + """ + return GRPCError(Status.UNIMPLEMENTED, 'Operation not available', [ + ErrorInfo(reason='UNIMPLEMENTED', domain=domain, metadata=metadata)]) + + +def get_incompatible_version_error(client_version: str, server_version: str, *, + domain: str = None, **metadata: str) \ + -> GRPCError: + """Build a :exc:`~grpclib.exceptions.GRPCError` exception for an + incompatible version error. + + Args: + client_version: The client version string. + server_version: The server version string. + domain: The domain string to include in the error. + metadata: Additional metadata to include in the error. + + """ + metadata = {'client_version': client_version, + 'server_version': server_version, + **metadata} + return GRPCError(Status.FAILED_PRECONDITION, 'Incompatible version', [ + ErrorInfo(reason='INCOMPATIBLE', domain=domain, metadata=metadata)]) diff --git a/pymap/admin/handlers/__init__.py b/pymap/admin/handlers/__init__.py index 91ec389f..5343a714 100644 --- a/pymap/admin/handlers/__init__.py +++ b/pymap/admin/handlers/__init__.py @@ -3,17 +3,20 @@ from abc import abstractmethod, ABCMeta from contextlib import closing, asynccontextmanager, AsyncExitStack -from typing import Any, Type, Dict, AsyncGenerator +from typing import Any, Type, Optional, Mapping, Dict, AsyncGenerator from typing_extensions import Final from pymap.context import connection_exit -from pymap.exceptions import ResponseError +from pymap.exceptions import InvalidAuth, ResponseError from pymap.interfaces.backend import BackendInterface +from pymap.interfaces.login import IdentityInterface from pymap.interfaces.session import SessionInterface from pymap.plugin import Plugin -from pymapadmin.grpc.admin_pb2 import Login, Result, SUCCESS, FAILURE +from pymapadmin.grpc.admin_pb2 import Result, SUCCESS, FAILURE from pysasl import AuthenticationCredentials -from pysasl.external import ExternalResult + +from ..errors import get_unimplemented_error +from ..token import TokenCredentials __all__ = ['handlers', 'BaseHandler', 'LoginHandler'] @@ -26,15 +29,17 @@ class BaseHandler(metaclass=ABCMeta): Args: backend: The backend in use by the system. - public: True if the requests are received from a public-facing client. + admin_token: The admin token string that can authenticate any admin + operation. """ - def __init__(self, backend: BackendInterface, public: bool) -> None: + def __init__(self, backend: BackendInterface, + admin_token: Optional[bytes]) -> None: super().__init__() self.config: Final = backend.config self.login: Final = backend.login - self.public: Final = public + self.admin_token: Final = admin_token @abstractmethod def __mapping__(self) -> Dict[str, Any]: @@ -53,42 +58,50 @@ async def catch_errors(self, command: str) -> AsyncGenerator[Result, None]: """ response = b'. OK %b completed.' % command.encode('utf-8') result = Result(code=SUCCESS, response=response) - try: - yield result - except ResponseError as exc: - result.code = FAILURE - result.response = bytes(exc.get_response(b'.')) - result.key = type(exc).__name__ + async with AsyncExitStack() as stack: + connection_exit.set(stack) + try: + yield result + except NotImplementedError as exc: + raise get_unimplemented_error() from exc + except ResponseError as exc: + result.code = FAILURE + result.response = bytes(exc.get_response(b'.')) + result.key = type(exc).__name__ + @asynccontextmanager + async def login_as(self, metadata: Mapping[str, str], user: str) \ + -> AsyncGenerator[IdentityInterface, None]: + """Context manager to login an identity object. -class LoginHandler(BaseHandler, metaclass=ABCMeta): - """Base class for implementing admin request handlers that login as a user - to handle requests. + Args: + stream: The grpc request/response stream. + user: The user to authorize as. - Args: - backend: The backend in use by the system. + Raises: + :class:`~pymap.exceptions.InvalidAuth` - """ + """ + try: + creds: AuthenticationCredentials = TokenCredentials( + metadata['auth-token'], self.admin_token, user) + except (KeyError, ValueError) as exc: + raise InvalidAuth() from exc + yield await self.login.authenticate(creds) @asynccontextmanager - async def login_as(self, login: Login) \ + async def with_session(self, identity: IdentityInterface) \ -> AsyncGenerator[SessionInterface, None]: - """Context manager to login as a user and provide a session object. + """Context manager to create a mail session for the identity. Args: - login: Holds the auuthentication credentials from the request. + identity: The authenticated user identity. Raises: :class:`~pymap.exceptions.InvalidAuth` """ - if self.public: - creds = AuthenticationCredentials( - login.authcid, login.secret, login.authzid or None) - else: - creds = ExternalResult(login.authzid or None) - async with AsyncExitStack() as stack: - connection_exit.set(stack) - session = await stack.enter_async_context(self.login(creds)) - stack.enter_context(closing(session)) - yield session + stack = connection_exit.get() + session = await stack.enter_async_context(identity.new_session()) + stack.enter_context(closing(session)) + yield session diff --git a/pymap/admin/handlers/mailbox.py b/pymap/admin/handlers/mailbox.py index 2b5bd64e..0f888f77 100644 --- a/pymap/admin/handlers/mailbox.py +++ b/pymap/admin/handlers/mailbox.py @@ -10,14 +10,14 @@ from pymapadmin.grpc.admin_grpc import MailboxBase from pymapadmin.grpc.admin_pb2 import AppendRequest, AppendResponse -from . import LoginHandler +from . import BaseHandler __all__ = ['MailboxHandlers'] _AppendStream = Stream[AppendRequest, AppendResponse] -class MailboxHandlers(MailboxBase, LoginHandler): +class MailboxHandlers(MailboxBase, BaseHandler): """The GRPC handlers, executed when an admin request is received. Each handler should receive a request, take action, and send the response. @@ -56,7 +56,8 @@ async def Append(self, stream: _AppendStream) -> None: validity: Optional[int] = None uid: Optional[int] = None async with self.catch_errors('Append') as result, \ - self.login_as(request.login) as session: + self.login_as(stream.metadata, request.user) as identity, \ + self.with_session(identity) as session: if session.filter_set is not None: filter_value = await session.filter_set.get_active() if filter_value is not None: diff --git a/pymap/admin/handlers/system.py b/pymap/admin/handlers/system.py index 49ecf1dd..7202951d 100644 --- a/pymap/admin/handlers/system.py +++ b/pymap/admin/handlers/system.py @@ -1,15 +1,23 @@ from __future__ import annotations +from datetime import datetime +from typing import Optional + from grpclib.server import Stream -from pymap import __version__ as server_version +from pymap import __version__ as pymap_version +from pymapadmin import __version__ as pymap_admin_version from pymapadmin.grpc.admin_grpc import SystemBase -from pymapadmin.grpc.admin_pb2 import PingRequest, PingResponse +from pymapadmin.grpc.admin_pb2 import LoginRequest, LoginResponse, \ + PingRequest, PingResponse +from pysasl.creds import AuthenticationCredentials from . import BaseHandler +from ..token import get_login_token __all__ = ['SystemHandlers'] +_LoginStream = Stream[LoginRequest, LoginResponse] _PingStream = Stream[PingRequest, PingResponse] @@ -22,6 +30,38 @@ class SystemHandlers(SystemBase, BaseHandler): """ + async def Login(self, stream: _LoginStream) -> None: + """Response to a login request. For example:: + + $ pymap-admin login + + See ``pymap-admin login --help`` for more options. + + Args: + stream (:class:`~grpclib.server.Stream`): The stream for the + request and response. + + """ + request = await stream.recv_message() + assert request is not None + authzid = request.authzid or None + bearer_token: Optional[str] = None + async with self.catch_errors('Login') as result: + credentials = AuthenticationCredentials( + request.authcid, request.secret, request.authzid) + expiration: Optional[datetime] = None + if request.token_expiration: + expiration = datetime.fromtimestamp(request.token_expiration) + identity = await self.login.authenticate(credentials) + token_data = await identity.new_token(expiration=expiration) + if token_data is not None: + macaroon = get_login_token( + token_data.identifier, token_data.key, + authzid=authzid, expiration=expiration) + bearer_token = macaroon.serialize() + resp = LoginResponse(result=result, bearer_token=bearer_token) + await stream.send_message(resp) + async def Ping(self, stream: _PingStream) -> None: """Respond to a ping request. For example:: @@ -36,6 +76,6 @@ async def Ping(self, stream: _PingStream) -> None: """ request = await stream.recv_message() assert request is not None - resp = PingResponse(server_version=server_version, - public_client=self.public) + resp = PingResponse(pymap_version=pymap_version, + pymap_admin_version=pymap_admin_version) await stream.send_message(resp) diff --git a/pymap/admin/handlers/user.py b/pymap/admin/handlers/user.py index 44545002..325b271b 100644 --- a/pymap/admin/handlers/user.py +++ b/pymap/admin/handlers/user.py @@ -4,24 +4,21 @@ from typing import Optional from grpclib.server import Stream -from pymap.exceptions import NotSupportedError from pymap.user import UserMetadata from pymapadmin.grpc.admin_grpc import UserBase from pymapadmin.grpc.admin_pb2 import \ - ListUsersRequest, ListUsersResponse, UserData, UserResponse, \ - GetUserRequest, SetUserRequest, DeleteUserRequest + UserData, UserResponse, GetUserRequest, SetUserRequest, DeleteUserRequest -from . import LoginHandler +from . import BaseHandler __all__ = ['UserHandlers'] -_ListUsersStream = Stream[ListUsersRequest, ListUsersResponse] _GetUserStream = Stream[GetUserRequest, UserResponse] _SetUserStream = Stream[SetUserRequest, UserResponse] _DeleteUserStream = Stream[DeleteUserRequest, UserResponse] -class UserHandlers(UserBase, LoginHandler): +class UserHandlers(UserBase, BaseHandler): """The GRPC handlers, executed when an admin request is received. Each handler should receive a request, take action, and send the response. @@ -30,29 +27,6 @@ class UserHandlers(UserBase, LoginHandler): """ - async def ListUsers(self, stream: _ListUsersStream) -> None: - """ - - See ``pymap-admin get-user --help`` for more options. - - Args: - stream (:class:`~grpclib.server.Stream`): The stream for the - request and response. - - """ - request = await stream.recv_message() - assert request is not None - async with self.catch_errors('ListUsers') as result, \ - self.login_as(request.login) as session: - if session.users is None: - raise NotSupportedError() - match = request.match or None - async for users in session.users.list_users(match=match): - resp = ListUsersResponse(users=users) - await stream.send_message(resp) - resp = ListUsersResponse(result=result) - await stream.send_message(resp) - async def GetUser(self, stream: _GetUserStream) -> None: """ @@ -68,11 +42,9 @@ async def GetUser(self, stream: _GetUserStream) -> None: username: Optional[str] = None user_data: Optional[UserData] = None async with self.catch_errors('GetUser') as result, \ - self.login_as(request.login) as session: - if session.users is None: - raise NotSupportedError() - username = session.owner - metadata = await session.users.get_user(username) + self.login_as(stream.metadata, request.user) as identity: + username = identity.name + metadata = await identity.get() user_data = UserData(password=metadata.password, params=metadata.params) resp = UserResponse(result=result, username=username, @@ -92,15 +64,13 @@ async def SetUser(self, stream: _SetUserStream) -> None: request = await stream.recv_message() assert request is not None async with self.catch_errors('SetUser') as result, \ - self.login_as(request.login) as session: - if session.users is None: - raise NotSupportedError() + self.login_as(stream.metadata, request.user) as identity: user_data = request.data password = self.config.hash_context.hash(user_data.password) - metadata = UserMetadata(self.config, password, - params=user_data.params) - await session.users.set_user(session.owner, metadata) - resp = UserResponse(result=result) + metadata = UserMetadata(self.config, password=password, + **user_data.params) + await identity.set(metadata) + resp = UserResponse(result=result, username=request.user) await stream.send_message(resp) async def DeleteUser(self, stream: _DeleteUserStream) -> None: @@ -116,9 +86,7 @@ async def DeleteUser(self, stream: _DeleteUserStream) -> None: request = await stream.recv_message() assert request is not None async with self.catch_errors('DeleteUser') as result, \ - self.login_as(request.login) as session: - if session.users is None: - raise NotSupportedError() - await session.users.delete_user(session.owner) - resp = UserResponse(result=result) + self.login_as(stream.metadata, request.user) as identity: + await identity.delete() + resp = UserResponse(result=result, username=request.user) await stream.send_message(resp) diff --git a/pymap/admin/token.py b/pymap/admin/token.py new file mode 100644 index 00000000..a89dc552 --- /dev/null +++ b/pymap/admin/token.py @@ -0,0 +1,159 @@ + +from __future__ import annotations + +import re +from datetime import datetime, timezone +from typing import Optional +from typing_extensions import Final + +from pymacaroons import Macaroon, Verifier +from pymacaroons.exceptions import MacaroonDeserializationException, \ + MacaroonInvalidSignatureException +from pysasl.creds import StoredSecret, AuthenticationCredentials + +__all__ = ['get_login_token', 'get_admin_token', 'TokenCredentials'] + + +def get_login_token(identifier: str, key: bytes, *, location: str = None, + expiration: datetime = None, + authzid: str = None) -> Macaroon: + """Produce a token :class:`~pymacaroons.Macaroon` to authenticate as a + specific user. + + Args: + identifier: Used to identify and lookup the *key* value. + key: The key used to create and verify the token for the user. + location: Optional hint describing where the token is valid. + expiration: When the token should expire. + authzid: If given, limits the token to the given authorization ID. + + """ + macaroon = Macaroon(location=location, identifier=identifier, key=key) + macaroon.add_first_party_caveat('type = login') + if authzid is not None: + macaroon.add_first_party_caveat(f'authzid = {authzid}') + if expiration is not None: + macaroon.add_first_party_caveat(f'time < {expiration.isoformat()}') + return macaroon + + +def get_admin_token(admin_token: bytes, expiration: Optional[datetime], *, + location: str = None, authzid: str = None) -> Macaroon: + """Produce a token :class:`~pymacaroons.Macaroon` to authenticate as an + admin that may perform operations on users. + + Args: + admin_token: The admin token string. + expiration: When the token should expire. + location: Optional hint describing where the token is valid. + authzid: If given, limits the token to the given authorization ID. + + """ + macaroon = Macaroon(location=location, identifier='', key=admin_token) + macaroon.add_first_party_caveat('type = admin') + if authzid is not None: + macaroon.add_first_party_caveat(f'authzid = {authzid}') + if expiration is not None: + macaroon.add_first_party_caveat(f'time < {expiration.isoformat()}') + return macaroon + + +class TokenCredentials(AuthenticationCredentials): + """Authenticate using a `Macaroon`_ token. Tokens may be created with + either :func:`get_login_token` or :func:`get_admin_token`. + + Either type of token may use a caveat with ``time < `` and a + :func:`~datetime.datetime.fromisoformat` string to limit how long the token + is valid. + + .. Macaroon: https://github.com/ecordell/pymacaroons#readme + + Args: + serialized: The serialized macaroon string. + admin_token: The admin token string. + identity: The identity to be authorized. + + """ + + _caveat = re.compile(r'^(\w+) ([^\w\s]+) (.*)$', re.ASCII) + + def __init__(self, serialized: str, admin_token: Optional[bytes], + identity: str) -> None: + try: + macaroon = Macaroon.deserialize(serialized) + except MacaroonDeserializationException as exc: + raise ValueError('invalid token') from exc + super().__init__(macaroon.identifier, '', identity) + self.macaroon: Final = macaroon + self.admin_token: Final = admin_token + self._type = self._find_type(macaroon) + + @classmethod + def _find_type(cls, macaroon: Macaroon) -> Optional[str]: + for caveat in macaroon.first_party_caveats(): + caveat_id = caveat.caveat_id + if caveat_id == 'type = admin': + return 'admin-token' + elif caveat_id == 'type = login': + return 'login-token' + return None + + @property + def authcid_type(self) -> Optional[str]: + return self._type + + @property + def has_secret(self) -> bool: + return False + + @property + def secret(self) -> str: + raise AttributeError('secret') + + def _satisfy(self, predicate: str) -> bool: + match = self._caveat.match(predicate) + if match is None: + return False + key, op, value = match.groups() + if key == 'time' and op == '<': + try: + end = datetime.fromisoformat(value) + except ValueError: + return False + now = datetime.now(tz=timezone.utc) + return now < end + elif key == 'authzid' and op == '=': + return value == self.authzid + else: + return False + + def _verify(self, verifier: Verifier, key: bytes) -> bool: + try: + return verifier.verify(self.macaroon, key) + except MacaroonInvalidSignatureException: + return False + + def _get_login_verifier(self) -> Verifier: + verifier = Verifier() + verifier.satisfy_general(self._satisfy) + verifier.satisfy_exact('type = login') + return verifier + + def _get_admin_verifier(self) -> Verifier: + verifier = Verifier() + verifier.satisfy_general(self._satisfy) + verifier.satisfy_exact('type = admin') + return verifier + + def check_secret(self, secret: Optional[StoredSecret], *, + key: bytes = None, **other: Optional[str]) -> bool: + if key is not None: + verifier = self._get_login_verifier() + if self._verify(verifier, key): + return True + admin_token = self.admin_token + if admin_token is not None: + verifier = self._get_admin_verifier() + if self._verify(verifier, admin_token): + return True + return False diff --git a/pymap/backend/dict/__init__.py b/pymap/backend/dict/__init__.py index 0e21868f..5122be27 100644 --- a/pymap/backend/dict/__init__.py +++ b/pymap/backend/dict/__init__.py @@ -3,27 +3,34 @@ import asyncio import os.path +import uuid from argparse import ArgumentParser, Namespace from contextlib import closing, asynccontextmanager from datetime import datetime, timezone -from typing import Any, Tuple, Sequence, Mapping, Dict, Awaitable, \ +from secrets import token_bytes +from typing import Any, Optional, Tuple, Sequence, Mapping, Dict, Awaitable, \ AsyncIterator +from typing_extensions import Final from pkg_resources import resource_listdir, resource_stream -from pysasl import AuthenticationCredentials +from pysasl.creds import AuthenticationCredentials +from pysasl.hashing import Cleartext from pymap.config import BackendCapability, IMAPConfig -from pymap.exceptions import InvalidAuth +from pymap.exceptions import AuthorizationFailure, NotAllowedError, \ + UserNotFound from pymap.interfaces.backend import BackendInterface, ServiceInterface -from pymap.interfaces.session import LoginProtocol +from pymap.interfaces.login import LoginTokenData, LoginInterface, \ + IdentityInterface from pymap.parsing.message import AppendMessage from pymap.parsing.specials.flag import Flag, Recent +from pymap.user import UserMetadata from .filter import FilterSet from .mailbox import Message, MailboxData, MailboxSet from ..session import BaseSession -__all__ = ['DictBackend', 'Config', 'Session'] +__all__ = ['DictBackend', 'Config'] class DictBackend(BackendInterface): @@ -41,10 +48,6 @@ def __init__(self, login: Login, config: Config) -> None: def login(self) -> Login: return self._login - @property - def users(self) -> None: - return None - @property def config(self) -> Config: return self._config @@ -77,7 +80,7 @@ class Config(IMAPConfig): def __init__(self, args: Namespace, *, demo_data: bool, demo_user: str, demo_password: str, demo_data_resource: str = __name__, **extra: Any) -> None: - super().__init__(args, **extra) + super().__init__(args, hash_context=Cleartext(), **extra) self._demo_data = demo_data self._demo_user = demo_user self._demo_password = demo_password @@ -145,44 +148,73 @@ def filter_set(self) -> FilterSet: return self._filter_set -class Login(LoginProtocol): +class Login(LoginInterface): """The login implementation for the dict backend.""" def __init__(self, config: Config) -> None: super().__init__() self.config = config + self.users = {config.demo_user: UserMetadata( + config, password=config.demo_password)} + self.tokens: Dict[str, Tuple[str, bytes]] = {} + + async def authenticate(self, credentials: AuthenticationCredentials) \ + -> Identity: + authcid = credentials.authcid + token_key: Optional[bytes] = None + role: Optional[str] = None + if credentials.authcid_type == 'login-token': + if authcid in self.tokens: + authcid, token_key = self.tokens[authcid] + elif credentials.authcid_type == 'admin-token': + authcid = credentials.identity + role = 'admin' + try: + metadata = await Identity(authcid, self, None).get() + except UserNotFound: + metadata = UserMetadata(self.config) + await metadata.check_password(credentials, token_key=token_key) + role = role or metadata.role + if role != 'admin' and authcid != credentials.identity: + raise AuthorizationFailure() + return Identity(credentials.identity, self, role) + + +class Identity(IdentityInterface): + """The identity implementation for the dict backend.""" + + def __init__(self, name: str, login: Login, role: Optional[str]) -> None: + super().__init__() + self.login: Final = login + self.config: Final = login.config + self._name = name + self._role = role - @asynccontextmanager - async def __call__(self, credentials: AuthenticationCredentials) \ - -> AsyncIterator[Session]: - """Checks the given credentials for a valid login and returns a new - session. The mailbox data is shared between concurrent and future - sessions, but only for the lifetime of the process. + @property + def name(self) -> str: + return self._name - """ - user = credentials.authcid + async def new_token(self, *, expiration: datetime = None) \ + -> LoginTokenData: + token_id = uuid.uuid4().hex + token_key = token_bytes() + self.login.tokens[token_id] = (self.name, token_key) + return LoginTokenData(self.name, token_id, token_key) + + @asynccontextmanager + async def new_session(self) -> AsyncIterator[Session]: + identity = self.name config = self.config - password = self._get_password(user) - if user != credentials.identity: - raise InvalidAuth() - elif not credentials.check_secret(password): - raise InvalidAuth() - mailbox_set, filter_set = config.set_cache.get(user, (None, None)) + _ = await self.get() + mailbox_set, filter_set = config.set_cache.get(identity, (None, None)) if not mailbox_set or not filter_set: mailbox_set = MailboxSet() filter_set = FilterSet() - if config.demo_data: + if config.demo_data and identity == config.demo_user: await self._load_demo(config.demo_data_resource, mailbox_set, filter_set) - config.set_cache[user] = (mailbox_set, filter_set) - yield Session(credentials.identity, config, mailbox_set, filter_set) - - def _get_password(self, user: str) -> str: - expected_user: str = self.config.demo_user - expected_password: str = self.config.demo_password - if user == expected_user: - return expected_password - raise InvalidAuth() + config.set_cache[identity] = (mailbox_set, filter_set) + yield Session(identity, config, mailbox_set, filter_set) async def _load_demo(self, resource: str, mailbox_set: MailboxSet, filter_set: FilterSet) -> None: @@ -229,3 +261,24 @@ async def _load_demo_mailbox(self, resource: str, name: str, msg_recent = False append_msg = AppendMessage(msg_data, msg_dt, frozenset(msg_flags)) await mbx.append(append_msg, recent=msg_recent) + + async def get(self) -> UserMetadata: + data = self.login.users.get(self.name) + if data is None: + raise UserNotFound(self.name) + return data + + async def set(self, data: UserMetadata) -> None: + if self._role != 'admin' and data.role: + raise NotAllowedError('Cannot assign role.') + self.login.users[self.name] = data + + async def delete(self) -> None: + try: + del self.login.users[self.name] + except KeyError as exc: + raise UserNotFound(self.name) from exc + self.config.set_cache.pop(self.name, None) + for token_id, (name, _) in list(self.login.tokens.items()): + if name == self.name: + del self.login.tokens[token_id] diff --git a/pymap/backend/maildir/__init__.py b/pymap/backend/maildir/__init__.py index f8f74390..027e9933 100644 --- a/pymap/backend/maildir/__init__.py +++ b/pymap/backend/maildir/__init__.py @@ -6,23 +6,26 @@ from argparse import ArgumentParser, Namespace from concurrent.futures import ThreadPoolExecutor from contextlib import asynccontextmanager +from datetime import datetime from typing import Any, Optional, Tuple, Sequence, Mapping, Awaitable, \ AsyncIterator +from typing_extensions import Final -from pysasl import AuthenticationCredentials +from pysasl.creds import AuthenticationCredentials from pymap.concurrent import Subsystem from pymap.config import BackendCapability, IMAPConfig -from pymap.exceptions import InvalidAuth +from pymap.exceptions import AuthorizationFailure, NotSupportedError from pymap.filter import PluginFilterSet, SingleFilterSet from pymap.interfaces.backend import BackendInterface, ServiceInterface -from pymap.interfaces.session import LoginProtocol +from pymap.interfaces.login import LoginInterface, IdentityInterface +from pymap.user import UserMetadata from .layout import MaildirLayout from .mailbox import Message, Maildir, MailboxSet from ..session import BaseSession -__all__ = ['MaildirBackend', 'Config', 'Session'] +__all__ = ['MaildirBackend', 'Config'] class MaildirBackend(BackendInterface): @@ -202,54 +205,70 @@ def filter_set(self) -> FilterSet: return self._filter_set -class Login(LoginProtocol): +class Login(LoginInterface): """The login implementation for the maildir backend.""" def __init__(self, config: Config) -> None: super().__init__() self.config = config - @asynccontextmanager - async def __call__(self, credentials: AuthenticationCredentials) \ - -> AsyncIterator[Session]: - """Checks the given credentials for a valid login and returns a new - session. + async def authenticate(self, credentials: AuthenticationCredentials) \ + -> Identity: + authcid = credentials.authcid + identity = credentials.identity + password: Optional[str] = None + mailbox_path: Optional[str] = None + with open(self.config.users_file, 'r') as users_file: + for line in users_file: + this_user, this_user_dir, this_password = line.split(':', 2) + if authcid == this_user: + password = this_password.rstrip('\r\n') + if identity == this_user: + mailbox_path = this_user_dir or this_user + data = UserMetadata(self.config, password=password) + await data.check_password(credentials) + if mailbox_path is None or authcid != identity: + raise AuthorizationFailure() + return Identity(self.config, identity, data, mailbox_path) + + +class Identity(IdentityInterface): + """The identity implementation for the maildir backend.""" + + def __init__(self, config: Config, name: str, metadata: UserMetadata, + mailbox_path: str) -> None: + super().__init__() + self.config: Final = config + self.metadata: Final = metadata + self.mailbox_path: Final = mailbox_path + self._name = name - """ - user = credentials.authcid + @property + def name(self) -> str: + return self._name + + async def new_token(self, *, expiration: datetime = None) -> None: + return None + + @asynccontextmanager + async def new_session(self) -> AsyncIterator[Session]: config = self.config - password, user_dir = await self.find_user(user) - if user != credentials.identity: - raise InvalidAuth() - elif not credentials.check_secret(password): - raise InvalidAuth() - maildir, layout = self._load_maildir(user_dir) + maildir, layout = self._load_maildir() mailbox_set = MailboxSet(maildir, layout) filter_set = FilterSet(layout.path) - yield Session(credentials.identity, config, mailbox_set, filter_set) + yield Session(self.name, config, mailbox_set, filter_set) - async def find_user(self, user: str) -> Tuple[str, str]: - """If the given user ID exists, return its expected password and - mailbox path. Override this method to implement custom login logic. - - Args: - config: The maildir config object. - user: The expected user ID. - - Raises: - InvalidAuth: The user ID was not valid. - - """ - with open(self.config.users_file, 'r') as users_file: - for line in users_file: - this_user, user_dir, password = line.split(':', 2) - if user == this_user: - return password.rstrip('\r\n'), user_dir or user - raise InvalidAuth() - - def _load_maildir(self, user_dir: str) \ - -> Tuple[Maildir, MaildirLayout]: - full_path = os.path.join(self.config.base_dir, user_dir) + def _load_maildir(self) -> Tuple[Maildir, MaildirLayout]: + full_path = os.path.join(self.config.base_dir, self.mailbox_path) layout = MaildirLayout.get(full_path, self.config.layout, Maildir) create = not os.path.exists(full_path) return Maildir(full_path, create=create), layout + + async def get(self) -> UserMetadata: + return self.metadata + + async def set(self, metadata: UserMetadata) -> None: + raise NotSupportedError() + + async def delete(self) -> None: + raise NotSupportedError() diff --git a/pymap/backend/redis/__init__.py b/pymap/backend/redis/__init__.py index 2c542878..d8dda7e1 100644 --- a/pymap/backend/redis/__init__.py +++ b/pymap/backend/redis/__init__.py @@ -6,21 +6,24 @@ import uuid from argparse import ArgumentParser, Namespace from contextlib import closing, asynccontextmanager +from datetime import datetime from functools import partial +from secrets import token_bytes from typing import Any, Optional, Tuple, Mapping, Sequence, Awaitable, \ - AsyncIterator, AsyncIterable + AsyncIterator +from typing_extensions import Final from aioredis import create_redis, Redis # type: ignore -from pysasl import AuthenticationCredentials -from pysasl.external import ExternalResult +from pysasl.creds import AuthenticationCredentials from pymap.bytes import BytesFormat from pymap.config import BackendCapability, IMAPConfig from pymap.context import connection_exit -from pymap.exceptions import InvalidAuth, IncompatibleData, UserNotFound +from pymap.exceptions import AuthorizationFailure, IncompatibleData, \ + NotAllowedError, UserNotFound from pymap.interfaces.backend import BackendInterface, ServiceInterface -from pymap.interfaces.session import LoginProtocol -from pymap.interfaces.users import UsersInterface +from pymap.interfaces.login import LoginTokenData, LoginInterface, \ + IdentityInterface from pymap.user import UserMetadata from .cleanup import CleanupTask @@ -38,18 +41,14 @@ class RedisBackend(BackendInterface): """ - def __init__(self, users: Users, config: Config) -> None: + def __init__(self, login: Login, config: Config) -> None: super().__init__() - self._users = users + self._login = login self._config = config @property - def login(self) -> Users: - return self._users - - @property - def users(self) -> Users: - return self._users + def login(self) -> Login: + return self._login @property def config(self) -> Config: @@ -76,8 +75,8 @@ def add_subparser(cls, name: str, subparsers: Any) -> ArgumentParser: @classmethod async def init(cls, args: Namespace) -> Tuple[RedisBackend, Config]: config = Config.from_args(args) - users = Users(config) - return cls(users, config), config + login = Login(config) + return cls(login, config), config async def start(self, services: Sequence[ServiceInterface]) -> Awaitable: config = self._config @@ -199,12 +198,11 @@ class Session(BaseSession[Message]): resource = __name__ - def __init__(self, redis: Redis, owner: str, config: Config, users: Users, + def __init__(self, redis: Redis, owner: str, config: Config, mailbox_set: MailboxSet, filter_set: FilterSet) -> None: super().__init__(owner) self._redis = redis self._config = config - self._users = users self._mailbox_set = mailbox_set self._filter_set = filter_set @@ -212,10 +210,6 @@ def __init__(self, redis: Redis, owner: str, config: Config, users: Users, def config(self) -> IMAPConfig: return self._config - @property - def users(self) -> Users: - return self._users - @property def mailbox_set(self) -> MailboxSet: return self._mailbox_set @@ -225,8 +219,8 @@ def filter_set(self) -> FilterSet: return self._filter_set -class Users(LoginProtocol, UsersInterface): - """The users implementation for the redis backend.""" +class Login(LoginInterface): + """The login implementation for the redis backend.""" def __init__(self, config: Config) -> None: super().__init__() @@ -239,64 +233,70 @@ async def _connect_redis(cls, address: str) -> Redis: stack.enter_context(closing(redis)) return redis - async def list_users(self, *, match: str = None) \ - -> AsyncIterable[Sequence[str]]: - config = self.config - redis = await self._connect_redis(config.address) - users_root = config._users_root.end(b'') - if match is None: - match_key = users_root + b'*' - else: - match_key = users_root + match.encode('utf-8') - cur = b'0' - while cur: - cur, keys = await redis.scan(cur, match=match_key) - yield [key[len(users_root):] for key in keys - if key.startswith(users_root)] - - async def get_user(self, user: str) -> UserMetadata: + async def authenticate(self, credentials: AuthenticationCredentials) \ + -> Identity: config = self.config redis = await self._connect_redis(config.address) - data = await self._get_user(redis, user) - if data is None: - raise UserNotFound() - return data + authcid = credentials.authcid + token_key: Optional[bytes] = None + role: Optional[str] = None + if credentials.authcid_type == 'admin-token': + authcid = credentials.identity + role = 'admin' + try: + metadata = await Identity(config, redis, authcid, None).get() + except UserNotFound: + metadata = UserMetadata(config) + if 'key' in metadata.params: + token_key = bytes.fromhex(metadata.params['key']) + role = role or metadata.role + await metadata.check_password(credentials, token_key=token_key) + if role != 'admin' and authcid != credentials.identity: + raise AuthorizationFailure() + return Identity(config, redis, credentials.identity, role) + + +class Identity(IdentityInterface): + """The identity implementation for the redis backend.""" + + def __init__(self, config: Config, redis: Redis, name: str, + role: Optional[str]) -> None: + super().__init__() + self.config: Final = config + self._redis: Optional[Redis] = redis + self._name = name + self._role = role - async def set_user(self, user: str, data: UserMetadata) -> None: - config = self.config - redis = await self._connect_redis(config.address) - user_key = config._users_root.end(user.encode('utf-8')) - data_dict = data.to_dict() - if self.config.users_json: - json_data = json.dumps(data_dict) - await redis.set(user_key, json_data) - else: - multi = redis.multi_exec() - multi.delete(user_key) - multi.hmset_dict(user_key, data_dict) - await multi.execute() + @property + def name(self) -> str: + return self._name - async def delete_user(self, user: str) -> None: - config = self.config - redis = await self._connect_redis(config.address) - user_key = config._users_root.end(user.encode('utf-8')) - if not await redis.delete(user_key): - raise UserNotFound() + @property + def redis(self) -> Redis: + redis = self._redis + if redis is None: + # Other methods may not be called after new_session(), since it + # may have called SELECT on the connection. + raise RuntimeError() + return redis - @asynccontextmanager - async def __call__(self, credentials: AuthenticationCredentials) \ - -> AsyncIterator[Session]: - """Checks the given credentials for a valid login and returns a new - session. + async def new_token(self, *, expiration: datetime = None) \ + -> Optional[LoginTokenData]: + metadata = await self.get() + if 'key' not in metadata.params: + return None + key = bytes.fromhex(metadata.params['key']) + return LoginTokenData(self.name, self.name, key) - """ + @asynccontextmanager + async def new_session(self) -> AsyncIterator[Session]: config = self.config - redis = await self._connect_redis(config.address) - user = await self._check_user(redis, credentials) + redis = self.redis + self._redis = None if config.select is not None: await redis.select(config.select) global_keys = config._global_keys - namespace = await self._get_namespace(redis, global_keys, user) + namespace = await self._get_namespace(redis, global_keys, self.name) ns_keys = NamespaceKeys(global_keys, namespace) cl_keys = CleanupKeys(global_keys) mailbox_set = MailboxSet(redis, ns_keys, cl_keys) @@ -305,35 +305,7 @@ async def __call__(self, credentials: AuthenticationCredentials) \ await mailbox_set.add_mailbox('INBOX') except ValueError: pass - yield Session(redis, credentials.identity, config, self, - mailbox_set, filter_set) - - async def _check_user(self, redis: Redis, - credentials: AuthenticationCredentials) -> str: - if not isinstance(credentials, ExternalResult): - user = credentials.authcid - data = await self._get_user(redis, user) - if data is None: - raise InvalidAuth() - await data.check_password(credentials) - if user != credentials.identity: - raise InvalidAuth(authorization=True) - return credentials.identity - - async def _get_user(self, redis: Redis, user: str) \ - -> Optional[UserMetadata]: - user_bytes = user.encode('utf-8') - user_key = self.config._users_root.end(user_bytes) - if self.config.users_json: - json_data = await redis.get(user_key) - if json_data is None: - return None - data_dict = json.loads(json_data) - else: - data_dict = await redis.hgetall(user_key, encoding='utf-8') - if data_dict is None: - return None - return UserMetadata.from_dict(self.config, data_dict) + yield Session(redis, self.name, config, mailbox_set, filter_set) async def _get_namespace(self, redis: Redis, global_keys: GlobalKeys, user: str) -> bytes: @@ -348,3 +320,40 @@ async def _get_namespace(self, redis: Redis, global_keys: GlobalKeys, if int(version) != DATA_VERSION: raise IncompatibleData() return namespace + + async def get(self) -> UserMetadata: + redis = self.redis + user_bytes = self.name.encode('utf-8') + user_key = self.config._users_root.end(user_bytes) + if self.config.users_json: + json_data = await redis.get(user_key) + if json_data is None: + raise UserNotFound(self.name) + data_dict = json.loads(json_data) + else: + data_dict = await redis.hgetall(user_key, encoding='utf-8') + if data_dict is None: + raise UserNotFound(self.name) + return UserMetadata(self.config, **data_dict) + + async def set(self, metadata: UserMetadata) -> None: + config = self.config + redis = self.redis + if self._role != 'admin' and metadata.role: + raise NotAllowedError('Cannot assign role.') + user_key = config._users_root.end(self.name.encode('utf-8')) + user_dict = metadata.to_dict(key=token_bytes().hex()) + if self.config.users_json: + json_data = json.dumps(user_dict) + await redis.set(user_key, json_data) + else: + multi = redis.multi_exec() + multi.delete(user_key) + multi.hmset_dict(user_key, user_dict) + await multi.execute() + + async def delete(self) -> None: + config = self.config + user_key = config._users_root.end(self.name.encode('utf-8')) + if not await self.redis.delete(user_key): + raise UserNotFound(self.name) diff --git a/pymap/backend/session.py b/pymap/backend/session.py index 02887694..54b943ef 100644 --- a/pymap/backend/session.py +++ b/pymap/backend/session.py @@ -13,7 +13,6 @@ from pymap.interfaces.filter import FilterSetInterface from pymap.interfaces.message import MessageT from pymap.interfaces.session import SessionInterface -from pymap.interfaces.users import UsersInterface from pymap.mailbox import MailboxSnapshot from pymap.parsing.message import AppendMessage from pymap.parsing.specials import SequenceSet, SearchKey, ObjectId, \ @@ -46,10 +45,6 @@ def __init__(self, owner: str) -> None: def owner(self) -> str: return self._owner - @property - def users(self) -> Optional[UsersInterface]: - return None # no user management by default - @property @abstractmethod def config(self) -> IMAPConfig: diff --git a/pymap/exceptions.py b/pymap/exceptions.py index 0a300324..2eeccdd4 100644 --- a/pymap/exceptions.py +++ b/pymap/exceptions.py @@ -11,9 +11,10 @@ __all__ = ['ResponseError', 'CloseConnection', 'NotSupportedError', 'TemporaryFailure', 'SearchNotAllowed', 'InvalidAuth', - 'IncompatibleData', 'MailboxError', 'MailboxNotFound', - 'MailboxConflict', 'MailboxHasChildren', 'MailboxReadOnly', - 'AppendFailure', 'UserNotFound'] + 'AuthorizationFailure', 'NotAllowedError', 'IncompatibleData', + 'MailboxError', 'MailboxNotFound', 'MailboxConflict', + 'MailboxHasChildren', 'MailboxReadOnly', 'AppendFailure', + 'UserNotFound'] class ResponseError(Exception, metaclass=ABCMeta): @@ -78,17 +79,39 @@ class InvalidAuth(ResponseError): """ - def __init__(self, msg: str = 'Invalid authentication credentials.', *, - authorization: bool = False) -> None: + def __init__(self, msg: str = 'Invalid authentication credentials.') \ + -> None: super().__init__(msg) self._raw = msg.encode('utf-8') - if authorization: - self._code = ResponseCode.of(b'AUTHORIZATIONFAILED') - else: - self._code = ResponseCode.of(b'AUTHENTICATIONFAILED') def get_response(self, tag: bytes) -> ResponseNo: - return ResponseNo(tag, self._raw, self._code) + return ResponseNo(tag, self._raw, + ResponseCode.of(b'AUTHENTICATIONFAILED')) + + +class AuthorizationFailure(InvalidAuth): + """The credentials in ``LOGIN`` or ``AUTHENTICATE`` were authenticated but + failed to authorize as the requested identity. + + """ + + def __init__(self, msg: str = 'Authorization failed.') -> None: + super().__init__(msg) + + def get_response(self, tag: bytes) -> ResponseNo: + return ResponseNo(tag, self._raw, + ResponseCode.of(b'AUTHORIZATIONFAILED')) + + +class NotAllowedError(ResponseError): + """The operation is not allowed due to access controls.""" + + def __init__(self, msg: str = 'Operation not allowed.') -> None: + super().__init__(msg) + self._raw = msg.encode('utf-8') + + def get_response(self, tag: bytes) -> ResponseNo: + return ResponseNo(tag, self._raw, ResponseCode.of(b'NOPERM')) class IncompatibleData(InvalidAuth): diff --git a/pymap/imap/__init__.py b/pymap/imap/__init__.py index d720263b..40166df6 100644 --- a/pymap/imap/__init__.py +++ b/pymap/imap/__init__.py @@ -23,7 +23,7 @@ connection_exit from pymap.exceptions import ResponseError from pymap.interfaces.backend import ServiceInterface -from pymap.interfaces.session import LoginProtocol +from pymap.interfaces.login import LoginInterface from pymap.parsing.command import Command from pymap.parsing.commands import Commands from pymap.parsing.command.nonauth import AuthenticateCommand, StartTLSCommand @@ -106,7 +106,7 @@ class IMAPServer: __slots__ = ['commands', '_login', '_config'] - def __init__(self, login: LoginProtocol, config: IMAPConfig) -> None: + def __init__(self, login: LoginInterface, config: IMAPConfig) -> None: super().__init__() self.commands = config.commands self._login = login diff --git a/pymap/imap/state.py b/pymap/imap/state.py index 87e5611c..42a6bf7d 100644 --- a/pymap/imap/state.py +++ b/pymap/imap/state.py @@ -11,7 +11,8 @@ from pymap.context import socket_info, connection_exit from pymap.exceptions import NotSupportedError, CloseConnection from pymap.fetch import MessageAttributes -from pymap.interfaces.session import SessionInterface, LoginProtocol +from pymap.interfaces.login import LoginInterface +from pymap.interfaces.session import SessionInterface from pymap.parsing.command import CommandAuth, CommandNonAuth, CommandSelect, \ Command from pymap.parsing.command.any import CapabilityCommand, LogoutCommand, \ @@ -54,7 +55,7 @@ class ConnectionState: """ - def __init__(self, login: LoginProtocol, config: IMAPConfig) -> None: + def __init__(self, login: LoginInterface, config: IMAPConfig) -> None: super().__init__() self.config = config self.auth = config.initial_auth @@ -97,7 +98,8 @@ async def do_cleanup(self) -> None: async def _login(self, creds: AuthenticationCredentials) \ -> SessionInterface: stack = connection_exit.get() - return await stack.enter_async_context(self.login(creds)) + identity = await self.login.authenticate(creds) + return await stack.enter_async_context(identity.new_session()) async def do_greeting(self) -> CommandResponse: preauth_creds = self.config.preauth_credentials diff --git a/pymap/interfaces/backend.py b/pymap/interfaces/backend.py index 8d09aeb3..9227f299 100644 --- a/pymap/interfaces/backend.py +++ b/pymap/interfaces/backend.py @@ -6,7 +6,7 @@ from typing import Any, Tuple, Sequence, Awaitable from typing_extensions import Protocol -from .session import LoginProtocol +from .login import LoginInterface from ..config import IMAPConfig __all__ = ['BackendInterface', 'ServiceInterface'] @@ -64,11 +64,8 @@ async def start(self, services: Sequence[ServiceInterface]) -> Awaitable: @property @abstractmethod - def login(self) -> LoginProtocol: - """Login callback that takes authentication credentials and returns a - :class:`~pymap.interfaces.session.SessionInterface` object. - - """ + def login(self) -> LoginInterface: + """Login interface that handles authentication credentials.""" ... @property diff --git a/pymap/interfaces/login.py b/pymap/interfaces/login.py new file mode 100644 index 00000000..ca55ed79 --- /dev/null +++ b/pymap/interfaces/login.py @@ -0,0 +1,120 @@ + +from __future__ import annotations + +from abc import abstractmethod +from datetime import datetime +from typing import Optional, NamedTuple, AsyncContextManager +from typing_extensions import Protocol + +from pysasl import AuthenticationCredentials + +from .session import SessionInterface +from ..user import UserMetadata + +__all__ = ['LoginTokenData', 'LoginInterface', 'IdentityInterface'] + + +class LoginTokenData(NamedTuple): + """Data required to build or verify a login token. + + Args: + identity: The token identity, or the username to authorize as. + identifier: The token identifier, used to determine *key*. + key: The token key, used to securely hash and verify the token. + + """ + + identity: str + identifier: str + key: bytes + + +class LoginInterface(Protocol): + """Defines the authentication operations for backends.""" + + @abstractmethod + async def authenticate(self, credentials: AuthenticationCredentials) \ + -> IdentityInterface: + """Authenticate and authorize the credentials. + + Raises: + :exc:`~pymap.exceptions.InvalidAuth` + + """ + ... + + +class IdentityInterface(Protocol): + """Defines the operations available to a user identity that has been + authenticated and authorized. This user identity may or may not "exist" in + the backend. + + """ + + @property + def name(self) -> str: + """The SASL authorization identity of the logged-in user.""" + ... + + @abstractmethod + def new_session(self) -> AsyncContextManager[SessionInterface]: + """Authenticate and authorize the credentials, returning a new IMAP + session. + + Args: + credentials: Authentication credentials supplied by the user. + + Raises: + :class:`~pymap.exceptions.UserNotFound` + + """ + ... + + @abstractmethod + async def new_token(self, *, expiration: datetime = None) \ + -> Optional[LoginTokenData]: + """Authenticate and authorize the credentials, returning an identifier + and private key that can be used to create and verify tokens. + + Since tokens should use their own private key, backends may return + ``None`` if it does not support tokens or the user does not have a + private key. + + Args: + expiration: When the token should stop being valid. + + Raises: + :exc:`~pymap.exceptions.UserNotFound` + + """ + ... + + @abstractmethod + async def get(self) -> UserMetadata: + """Return the metadata associated with the user identity. + + Raises: + :exc:`~pymap.exceptions.UserNotFound` + + """ + ... + + @abstractmethod + async def set(self, metadata: UserMetadata) -> None: + """Assign new metadata to the user identity. + + Args: + metadata: New metadata, such as password. + + """ + ... + + @abstractmethod + async def delete(self) -> None: + """Delete existing metadata for the user identity. + + Raises: + :exc:`~pymap.exceptions.UserNotFound` + + """ + ... diff --git a/pymap/interfaces/session.py b/pymap/interfaces/session.py index c3489f2b..fb72c542 100644 --- a/pymap/interfaces/session.py +++ b/pymap/interfaces/session.py @@ -2,16 +2,12 @@ from __future__ import annotations from abc import abstractmethod -from typing import Any, Tuple, Optional, FrozenSet, Iterable, Sequence, \ - AsyncContextManager +from typing import Any, Tuple, Optional, FrozenSet, Iterable, Sequence from typing_extensions import Protocol -from pysasl import AuthenticationCredentials - from .filter import FilterSetInterface from .message import MessageInterface from .mailbox import MailboxInterface -from .users import UsersInterface from ..concurrent import Event from ..flags import FlagOp from ..parsing.message import AppendMessage @@ -19,29 +15,7 @@ from ..parsing.response.code import AppendUid, CopyUid from ..selected import SelectedMailbox -__all__ = ['LoginProtocol', 'SessionInterface'] - - -class LoginProtocol(Protocol): - """Defines the callback protocol that backends use to initialize a new - session. - - """ - - @abstractmethod - def __call__(self, credentials: AuthenticationCredentials) \ - -> AsyncContextManager[SessionInterface]: - """Given a set of authentication credentials, initialize and provide a - new IMAP session for the user. - - Args: - credentials: Authentication credentials supplied by the user. - - Raises: - :class:`~pymap.exceptions.InvalidAuth` - - """ - ... +__all__ = ['SessionInterface'] class SessionInterface(Protocol): @@ -55,12 +29,6 @@ def owner(self) -> str: """The SASL authorization identity of the logged-in user.""" ... - @property - @abstractmethod - def users(self) -> Optional[UsersInterface]: - """Handles user management.""" - ... - @property @abstractmethod def filter_set(self) -> Optional[FilterSetInterface[Any]]: diff --git a/pymap/interfaces/users.py b/pymap/interfaces/users.py deleted file mode 100644 index 82491da0..00000000 --- a/pymap/interfaces/users.py +++ /dev/null @@ -1,64 +0,0 @@ - -from __future__ import annotations - -from abc import abstractmethod -from typing import Collection, AsyncIterable -from typing_extensions import Protocol - -from ..user import UserMetadata - -__all__ = ['UsersInterface'] - - -class UsersInterface(Protocol): - """Defines admin functions that backends can implement to manage users.""" - - @abstractmethod - def list_users(self, *, match: str = None) \ - -> AsyncIterable[Collection[str]]: - """Iterate all matching users in batches. The format *match* argument - depends on the backend implementation. - - Args: - match: A filter string for matched users. - - """ - ... - - @abstractmethod - async def get_user(self, user: str) -> UserMetadata: - """Return the password and other metadata for a username. - - Args: - user: The user login string. - - Raises: - :class:`~pymap.exceptions.UserNotFound` - - """ - ... - - @abstractmethod - async def set_user(self, user: str, data: UserMetadata) -> None: - """Assign a password and other metadata to a username, creating it if - it does not exist. - - Args: - user: The user login string. - data: The user metadata, including password. - - """ - ... - - @abstractmethod - async def delete_user(self, user: str) -> None: - """Delete a user and all mail data associated with it. - - Args: - user: The user login string. - - Raises: - :class:`~pymap.exceptions.UserNotFound` - - """ - ... diff --git a/pymap/parsing/primitives.py b/pymap/parsing/primitives.py index fb7bcd16..a467214f 100644 --- a/pymap/parsing/primitives.py +++ b/pymap/parsing/primitives.py @@ -417,7 +417,7 @@ def __init__(self, items: Iterable[MaybeBytes], sort: bool = False) -> None: super().__init__() if sort: - items_list = sorted(items) + items_list = sorted(items) # type: ignore else: items_list = list(items) self.items: Sequence[MaybeBytes] = items_list diff --git a/pymap/parsing/response/code.py b/pymap/parsing/response/code.py index f95117ab..15d19bbf 100644 --- a/pymap/parsing/response/code.py +++ b/pymap/parsing/response/code.py @@ -51,8 +51,9 @@ class PermanentFlags(ResponseCode): def __init__(self, flags: Iterable[MaybeBytes]) -> None: super().__init__() - self.flags: Sequence[MaybeBytes] = sorted(flags) - self._raw = BytesFormat(b'[PERMANENTFLAGS %b]') % ListP(self.flags) + self.flags: Sequence[MaybeBytes] = list(flags) + self._raw = BytesFormat(b'[PERMANENTFLAGS %b]') \ + % ListP(self.flags, sort=True) def __bytes__(self) -> bytes: return self._raw diff --git a/pymap/plugin.py b/pymap/plugin.py index 3b49e8c9..a80e8711 100644 --- a/pymap/plugin.py +++ b/pymap/plugin.py @@ -3,6 +3,7 @@ from typing import TypeVar, Generic, Callable, Iterable, Iterator, \ Tuple, Mapping, Dict +from typing_extensions import Final from pkg_resources import iter_entry_points, DistributionNotFound @@ -26,13 +27,14 @@ class Plugin(Generic[PluginT], Iterable[Tuple[str, PluginT]]): to avoid cyclic imports. Args: - group: A entry point group to load. + group: The entry point group to load. """ - def __init__(self, group: str = None) -> None: + def __init__(self, group: str) -> None: super().__init__() - self._group = group + self.group: Final = group + self._loaded = False self._registered: Dict[str, PluginT] = {} def __iter__(self) -> Iterator[Tuple[str, PluginT]]: @@ -44,16 +46,24 @@ def registered(self) -> Mapping[str, PluginT]: self._load() return self._registered + @property + def first(self) -> PluginT: + """The first registered plugin.""" + first = next(iter(self.registered.values()), None) + assert first is not None, \ + f'plugin group {self.group} has no entries' + return first + def _load(self) -> None: - if self._group is not None: - for entry_point in iter_entry_points(self._group): + if not self._loaded: + for entry_point in iter_entry_points(self.group): try: plugin: PluginT = entry_point.load() except DistributionNotFound: pass # optional dependencies not installed else: self.add(entry_point.name, plugin) - self._group = None + self._loaded = True def add(self, name: str, plugin: PluginT) -> None: """Add a new plugin by name. @@ -64,7 +74,7 @@ def add(self, name: str, plugin: PluginT) -> None: """ assert name not in self._registered, \ - f'plugin {name!r} has already been registered' + f'plugin {self.group}:{name} has already been registered' self._registered[name] = plugin def register(self, name: str) -> Callable[[PluginT], PluginT]: @@ -80,4 +90,4 @@ def deco(plugin: PluginT) -> PluginT: return deco def __repr__(self) -> str: - return f'Plugin({self._group!r})' + return f'Plugin({self.group!r})' diff --git a/pymap/sieve/manage/__init__.py b/pymap/sieve/manage/__init__.py index 5a0e27f5..346d94ef 100644 --- a/pymap/sieve/manage/__init__.py +++ b/pymap/sieve/manage/__init__.py @@ -20,7 +20,8 @@ from pymap.context import socket_info, language_code, connection_exit from pymap.exceptions import InvalidAuth from pymap.interfaces.backend import ServiceInterface -from pymap.interfaces.session import LoginProtocol, SessionInterface +from pymap.interfaces.login import LoginInterface +from pymap.interfaces.session import SessionInterface from pymap.parsing.exceptions import NotParseable from pymap.parsing.primitives import String from pysasl import ServerChallenge, ChallengeResponse, AuthenticationError, \ @@ -83,7 +84,7 @@ class ManageSieveServer: """ - def __init__(self, login: LoginProtocol, config: IMAPConfig) -> None: + def __init__(self, login: LoginInterface, config: IMAPConfig) -> None: super().__init__() self._login = login self._config = config @@ -113,7 +114,7 @@ class ManageSieveConnection: _literal_plus = re.compile(br'{(\d+)\+}\r?\n$') _impl = b'pymap managesieve ' + __version__.encode('ascii') - def __init__(self, login: LoginProtocol, config: IMAPConfig, + def __init__(self, login: LoginInterface, config: IMAPConfig, reader: StreamReader, writer: StreamWriter) -> None: super().__init__() self.login = login @@ -204,7 +205,8 @@ async def _write_response(self, resp: Response) -> None: async def _login(self, creds: AuthenticationCredentials) \ -> SessionInterface: stack = connection_exit.get() - return await stack.enter_async_context(self.login(creds)) + identity = await self.login.authenticate(creds) + return await stack.enter_async_context(identity.new_session()) async def _do_greeting(self) -> Response: preauth_creds = self.config.preauth_credentials diff --git a/pymap/user.py b/pymap/user.py index fe19287d..84c63cc2 100644 --- a/pymap/user.py +++ b/pymap/user.py @@ -1,10 +1,10 @@ from __future__ import annotations -from typing import Any, Optional, Mapping, Dict +from typing import Optional, Mapping from typing_extensions import Final -from pysasl import AuthenticationCredentials, HashInterface +from pysasl.creds import StoredSecret, AuthenticationCredentials from .config import IMAPConfig from .exceptions import InvalidAuth @@ -13,75 +13,65 @@ class UserMetadata: - """Contains user metadata including the password or hash. + """Contains user metadata such as the password or hash. Args: config: The configuration object. password: The password string or hash digest. - params: Additional metadata parameters associated with the user. + params: Metadata parameters associated with the user. """ - def __init__(self, config: IMAPConfig, password: Optional[str], *, - params: Mapping[str, str] = None) -> None: + def __init__(self, config: IMAPConfig, *, password: str = None, + **params: Optional[str]) -> None: super().__init__() self.config: Final = config self.password: Final = password - self.params: Final = params or {} + self.params: Final = {key: val for key, val in params.items() + if val is not None} - @classmethod - def from_dict(cls, config: IMAPConfig, data: Mapping[str, Any]) \ - -> UserMetadata: - """Build a new :class:`UserMetadata` from a dictionary containing - the password information. Fields must either be :func:`str` or - :func:`int`, bytestrings should be hex-encoded. + @property + def role(self) -> Optional[str]: + """The value of the ``role`` key from *params*.""" + return self.params.get('role') - See Also: - :meth:`.to_dict` + def to_dict(self, **extra: str) -> Mapping[str, str]: + """Combines the *password*, *params*, and *extra* into a dictionary. Args: - config: The configuration object. - data: The password data dictionary. + extra: Additional parameters as keyword arguments, which will be + merged into the result. """ - params = dict(data) - password = params.pop('password', None) - return cls(config, password, params=params) + ret = dict(self.params, **extra) + if self.password is not None: + ret['password'] = self.password + return ret - def to_dict(self) -> Mapping[str, Any]: - """Returns the password comparison data in a JSON-serializable - dictionary for persistence and transfer. - - See Also: - :meth:`.from_dict` - - """ - data: Dict[str, Any] = dict(self.params) - if self.password: - data['password'] = self.password - return data - - async def check_password(self, creds: AuthenticationCredentials) -> None: + async def check_password(self, creds: AuthenticationCredentials, *, + token_key: bytes = None) -> None: """Check the given credentials against the known password comparison data. If the known data used a hash, then the equivalent hash of the provided secret is compared. Args: creds: The provided authentication credentials. + token_key: The token key bytestring. Raises: :class:`~pymap.exceptions.InvalidAuth` """ - if self.password is None: - raise InvalidAuth() hash_context = self.config.hash_context cpu_subsystem = self.config.cpu_subsystem - fut = self._check_secret(creds, self.password, hash_context) + stored_secret: Optional[StoredSecret] = None + if self.password is not None: + stored_secret = StoredSecret(self.password, hash=hash_context) + fut = self._check_secret(creds, stored_secret, token_key) if not await cpu_subsystem.execute(fut): raise InvalidAuth() async def _check_secret(self, creds: AuthenticationCredentials, - password: str, - hash_context: HashInterface) -> bool: - return creds.check_secret(password, hash=hash_context) + stored_secret: Optional[StoredSecret], + key: Optional[bytes]) -> bool: + return creds.check_secret(stored_secret, key=key) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..e3d75f75 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,11 @@ +[mypy-pymacaroons.*] +ignore_missing_imports = True + +[mypy-google.rpc.*] +ignore_missing_imports = True + +[coverage:report] +omit = */maildir/*, */redis/*, */main.py +exclude_lines = + pragma: no cover + ^\s*...\s*$ diff --git a/setup.py b/setup.py index da9281c2..6987c3f8 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ license = f.read() setup(name='pymap', - version='0.20.3', + version='0.21.0', author='Ian Good', author_email='ian@icgood.net', description='Lightweight, asynchronous IMAP serving in Python.', @@ -49,12 +49,13 @@ include_package_data=True, packages=find_packages(), install_requires=[ - 'pysasl >= 0.6.2', + 'pysasl >= 0.7.1', 'proxy-protocol >= 0.5.5', 'typing-extensions'], extras_require={ 'redis': ['aioredis >= 1.3.1', 'msgpack >= 1.0'], - 'admin': ['pymap-admin == 0.4.3'], + 'admin': ['pymap-admin == 0.5.0', 'pymacaroons', + 'googleapis-common-protos'], 'sieve': ['sievelib'], 'systemd': ['systemd-python'], 'optional': ['hiredis', 'passlib', 'pid']}, diff --git a/test/requirements.txt b/test/requirements.txt index 17d9d949..d74b53e9 100644 --- a/test/requirements.txt +++ b/test/requirements.txt @@ -10,5 +10,7 @@ msgpack grpclib protobuf sievelib -proxy-protocol +passlib +pymacaroons +googleapis-common-protos pymap-admin diff --git a/test/server/mocktransport.py b/test/server/mocktransport.py index 39058369..494f1a6d 100644 --- a/test/server/mocktransport.py +++ b/test/server/mocktransport.py @@ -64,14 +64,14 @@ def push_read_eof(self, wait=None, set=None): where = self._caller(inspect.currentframe()) self.queue.append((_Type.READ_EOF, where, None, wait, set)) - def push_login(self, wait=None, set=None): + def push_login(self, password=b'testpass', wait=None, set=None): self.push_write( b'* OK [CAPABILITY IMAP4rev1', (br'(?:\s+[a-zA-Z0-9=+-]+)*', ), b'] Server ready ', (br'\S+', ), b'\r\n', wait=wait) self.push_readline( - b'login1 LOGIN testuser testpass\r\n') + b'login1 LOGIN testuser ' + password + b'\r\n') self.push_write( b'login1 OK [CAPABILITY IMAP4rev1', (br'(?:\s+[a-zA-Z0-9=+-]+)*', ), diff --git a/test/server/test_admin_auth.py b/test/server/test_admin_auth.py new file mode 100644 index 00000000..8a428c74 --- /dev/null +++ b/test/server/test_admin_auth.py @@ -0,0 +1,114 @@ + +from typing import Mapping + +import pytest # type: ignore +from grpclib.testing import ChannelFor +from pymapadmin.grpc.admin_grpc import SystemStub, UserStub +from pymapadmin.grpc.admin_pb2 import SUCCESS, FAILURE, UserData, \ + LoginRequest, GetUserRequest, SetUserRequest, DeleteUserRequest + +from pymap.admin.handlers.system import SystemHandlers +from pymap.admin.handlers.user import UserHandlers + +from .base import TestBase + +pytestmark = pytest.mark.asyncio + + +class TestAdminAuth(TestBase): + + admin_token = 'MDAwZWxvY2F0aW9uIAowMDEwaWRlbnRpZmllciAKMDAxNWNpZCB0eXBl' \ + 'ID0gYWRtaW4KMDAyZnNpZ25hdHVyZSBTApt6-KNq85_1TeSmQyqTZjWPfHCYPY8EIG' \ + 'q6NMqv4go' + + async def test_token(self, backend) -> None: + token = await self._login(backend, 'testuser', 'testpass') + await self._get_user(backend, token, 'testuser') + await self._set_user(backend, token, 'testuser', 'newpass') + await self._delete_user(backend, token, 'testuser') + await self._get_user(backend, token, 'testuser', + failure_key='InvalidAuth') + await self._get_user(backend, self.admin_token, 'testuser', + failure_key='UserNotFound') + + async def test_authorization(self, backend) -> None: + await self._set_user(backend, self.admin_token, 'newuser', 'newpass') + token1 = await self._login(backend, 'testuser', 'testpass') + token2 = await self._login(backend, 'newuser', 'newpass') + await self._get_user(backend, token1, 'newuser', + failure_key='AuthorizationFailure') + await self._get_user(backend, token2, 'newuser') + await self._set_user(backend, token1, 'newuser', 'newpass2', + failure_key='AuthorizationFailure') + await self._set_user(backend, token2, 'newuser', 'newpass2') + await self._delete_user(backend, token1, 'newuser', + failure_key='AuthorizationFailure') + await self._delete_user(backend, token2, 'newuser') + + async def test_admin_role(self, backend) -> None: + token = await self._login(backend, 'testuser', 'testpass') + await self._set_user(backend, token, 'testuser', 'testpass', + params={'role': 'admin'}, + failure_key='NotAllowedError') + await self._set_user(backend, self.admin_token, 'testuser', 'testpass', + params={'role': 'admin'}) + await self._set_user(backend, token, 'testuser', 'testpass', + params={'role': 'admin'}) + await self._set_user(backend, token, 'newuser', 'newpass') + await self._delete_user(backend, token, 'newuser') + + async def _login(self, backend, authcid: str, secret: str) -> str: + handlers = SystemHandlers(backend, b'testadmintoken') + request = LoginRequest(authcid=authcid, secret=secret) + async with ChannelFor([handlers]) as channel: + stub = SystemStub(channel) + response = await stub.Login(request) + assert SUCCESS == response.result.code + return response.bearer_token + + async def _get_user(self, backend, token: str, user: str, *, + failure_key: str = None) -> None: + handlers = UserHandlers(backend, b'testadmintoken') + request = GetUserRequest(user=user) + metadata = {'auth-token': token} + async with ChannelFor([handlers]) as channel: + stub = UserStub(channel) + response = await stub.GetUser(request, metadata=metadata) + if failure_key is None: + assert SUCCESS == response.result.code + assert user == response.username + else: + assert FAILURE == response.result.code + assert failure_key == response.result.key + + async def _set_user(self, backend, token: str, user: str, password: str, *, + params: Mapping[str, str] = {}, + failure_key: str = None) -> None: + handlers = UserHandlers(backend, b'testadmintoken') + data = UserData(password=password, params=params) + request = SetUserRequest(user=user, data=data) + metadata = {'auth-token': token} + async with ChannelFor([handlers]) as channel: + stub = UserStub(channel) + response = await stub.SetUser(request, metadata=metadata) + if failure_key is None: + assert SUCCESS == response.result.code + assert user == response.username + else: + assert FAILURE == response.result.code + assert failure_key == response.result.key + + async def _delete_user(self, backend, token: str, user: str, *, + failure_key: str = None) -> None: + handlers = UserHandlers(backend, b'testadmintoken') + request = DeleteUserRequest(user=user) + metadata = {'auth-token': token} + async with ChannelFor([handlers]) as channel: + stub = UserStub(channel) + response = await stub.DeleteUser(request, metadata=metadata) + if failure_key is None: + assert SUCCESS == response.result.code + assert user == response.username + else: + assert FAILURE == response.result.code + assert failure_key == response.result.key diff --git a/test/server/test_admin_mailbox.py b/test/server/test_admin_mailbox.py index d9baebf6..615f23d3 100644 --- a/test/server/test_admin_mailbox.py +++ b/test/server/test_admin_mailbox.py @@ -2,7 +2,7 @@ import pytest # type: ignore from grpclib.testing import ChannelFor from pymapadmin.grpc.admin_grpc import MailboxStub -from pymapadmin.grpc.admin_pb2 import Login, AppendRequest, SUCCESS, FAILURE +from pymapadmin.grpc.admin_pb2 import AppendRequest, SUCCESS, FAILURE from pymap.admin.handlers.mailbox import MailboxHandlers @@ -13,16 +13,20 @@ class TestMailboxHandlers(TestBase): + admin_token = 'MDAwZWxvY2F0aW9uIAowMDEwaWRlbnRpZmllciAKMDAxNWNpZCB0eXBl' \ + 'ID0gYWRtaW4KMDAyZnNpZ25hdHVyZSBTApt6-KNq85_1TeSmQyqTZjWPfHCYPY8EIG' \ + 'q6NMqv4go' + metadata = {'auth-token': admin_token} + async def test_append(self, backend, imap_server) -> None: - handlers = MailboxHandlers(backend, True) + handlers = MailboxHandlers(backend, b'testadmintoken') data = b'From: user@example.com\n\ntest message!\n' - login = Login(authcid='testuser', secret='testpass') - request = AppendRequest(login=login, mailbox='INBOX', + request = AppendRequest(user='testuser', mailbox='INBOX', flags=['\\Flagged', '\\Seen'], when=1234567890, data=data) async with ChannelFor([handlers]) as channel: stub = MailboxStub(channel) - response = await stub.Append(request) + response = await stub.Append(request, metadata=self.metadata) assert SUCCESS == response.result.code assert 105 == response.uid @@ -45,161 +49,148 @@ async def test_append(self, backend, imap_server) -> None: await self.run(transport) async def test_append_user_not_found(self, backend) -> None: - handlers = MailboxHandlers(backend, True) - login = Login(authcid='testuser', secret='badpass') - request = AppendRequest(login=login) + handlers = MailboxHandlers(backend, b'testadmintoken') + request = AppendRequest(user='baduser') async with ChannelFor([handlers]) as channel: stub = MailboxStub(channel) - response = await stub.Append(request) + response = await stub.Append(request, metadata=self.metadata) assert FAILURE == response.result.code - assert 'InvalidAuth' == response.result.key + assert 'UserNotFound' == response.result.key async def test_append_mailbox_not_found(self, backend) -> None: - handlers = MailboxHandlers(backend, True) - login = Login(authcid='testuser', secret='testpass') - request = AppendRequest(login=login, mailbox='BAD') + handlers = MailboxHandlers(backend, b'testadmintoken') + request = AppendRequest(user='testuser', mailbox='BAD') async with ChannelFor([handlers]) as channel: stub = MailboxStub(channel) - response = await stub.Append(request) + response = await stub.Append(request, metadata=self.metadata) assert FAILURE == response.result.code assert 'BAD' == response.mailbox assert 'MailboxNotFound' == response.result.key async def test_append_filter_reject(self, backend) -> None: - handlers = MailboxHandlers(backend, True) + handlers = MailboxHandlers(backend, b'testadmintoken') data = b'Subject: reject this\n\ntest message!\n' - login = Login(authcid='testuser', secret='testpass') - request = AppendRequest(login=login, mailbox='INBOX', + request = AppendRequest(user='testuser', mailbox='INBOX', flags=['\\Flagged', '\\Seen'], when=1234567890, data=data) async with ChannelFor([handlers]) as channel: stub = MailboxStub(channel) - response = await stub.Append(request) + response = await stub.Append(request, metadata=self.metadata) assert FAILURE == response.result.code assert 'AppendFailure' == response.result.key async def test_append_filter_discard(self, backend) -> None: - handlers = MailboxHandlers(backend, True) + handlers = MailboxHandlers(backend, b'testadmintoken') data = b'Subject: discard this\n\ntest message!\n' - login = Login(authcid='testuser', secret='testpass') - request = AppendRequest(login=login, mailbox='INBOX', + request = AppendRequest(user='testuser', mailbox='INBOX', flags=['\\Flagged', '\\Seen'], when=1234567890, data=data) async with ChannelFor([handlers]) as channel: stub = MailboxStub(channel) - response = await stub.Append(request) + response = await stub.Append(request, metadata=self.metadata) assert SUCCESS == response.result.code assert not response.mailbox assert not response.uid async def test_append_filter_address_is(self, backend) -> None: - handlers = MailboxHandlers(backend, True) + handlers = MailboxHandlers(backend, b'testadmintoken') data = b'From: foo@example.com\n\ntest message!\n' - login = Login(authcid='testuser', secret='testpass') - request = AppendRequest(login=login, mailbox='INBOX', + request = AppendRequest(user='testuser', mailbox='INBOX', flags=['\\Flagged', '\\Seen'], when=1234567890, data=data) async with ChannelFor([handlers]) as channel: stub = MailboxStub(channel) - response = await stub.Append(request) + response = await stub.Append(request, metadata=self.metadata) assert 'Test 1' == response.mailbox async def test_append_filter_address_contains(self, backend) -> None: - handlers = MailboxHandlers(backend, True) + handlers = MailboxHandlers(backend, b'testadmintoken') data = b'From: user@foo.com\n\ntest message!\n' - login = Login(authcid='testuser', secret='testpass') - request = AppendRequest(login=login, mailbox='INBOX', + request = AppendRequest(user='testuser', mailbox='INBOX', flags=['\\Flagged', '\\Seen'], when=1234567890, data=data) async with ChannelFor([handlers]) as channel: stub = MailboxStub(channel) - response = await stub.Append(request) + response = await stub.Append(request, metadata=self.metadata) assert 'Test 2' == response.mailbox async def test_append_filter_address_matches(self, backend) -> None: - handlers = MailboxHandlers(backend, True) + handlers = MailboxHandlers(backend, b'testadmintoken') data = b'To: bigfoot@example.com\n\ntest message!\n' - login = Login(authcid='testuser', secret='testpass') - request = AppendRequest(login=login, mailbox='INBOX', + request = AppendRequest(user='testuser', mailbox='INBOX', flags=['\\Flagged', '\\Seen'], when=1234567890, data=data) async with ChannelFor([handlers]) as channel: stub = MailboxStub(channel) - response = await stub.Append(request) + response = await stub.Append(request, metadata=self.metadata) assert 'Test 3' == response.mailbox async def test_append_filter_envelope_is(self, backend) -> None: - handlers = MailboxHandlers(backend, True) + handlers = MailboxHandlers(backend, b'testadmintoken') data = b'From: user@example.com\n\ntest message!\n' - login = Login(authcid='testuser', secret='testpass') - request = AppendRequest(login=login, mailbox='INBOX', + request = AppendRequest(user='testuser', mailbox='INBOX', sender='foo@example.com', recipient=None, flags=['\\Flagged', '\\Seen'], when=1234567890, data=data) async with ChannelFor([handlers]) as channel: stub = MailboxStub(channel) - response = await stub.Append(request) + response = await stub.Append(request, metadata=self.metadata) assert 'Test 4' == response.mailbox async def test_append_filter_envelope_contains(self, backend) -> None: - handlers = MailboxHandlers(backend, True) + handlers = MailboxHandlers(backend, b'testadmintoken') data = b'From: user@example.com\n\ntest message!\n' - login = Login(authcid='testuser', secret='testpass') - request = AppendRequest(login=login, mailbox='INBOX', + request = AppendRequest(user='testuser', mailbox='INBOX', sender='user@foo.com', recipient=None, flags=['\\Flagged', '\\Seen'], when=1234567890, data=data) async with ChannelFor([handlers]) as channel: stub = MailboxStub(channel) - response = await stub.Append(request) + response = await stub.Append(request, metadata=self.metadata) assert 'Test 5' == response.mailbox async def test_append_filter_envelope_matches(self, backend) -> None: - handlers = MailboxHandlers(backend, True) + handlers = MailboxHandlers(backend, b'testadmintoken') data = b'From: user@example.com\n\ntest message!\n' - login = Login(authcid='testuser', secret='testpass') - request = AppendRequest(login=login, mailbox='INBOX', + request = AppendRequest(user='testuser', mailbox='INBOX', sender=None, recipient='bigfoot@example.com', flags=['\\Flagged', '\\Seen'], when=1234567890, data=data) async with ChannelFor([handlers]) as channel: stub = MailboxStub(channel) - response = await stub.Append(request) + response = await stub.Append(request, metadata=self.metadata) assert 'Test 6' == response.mailbox async def test_append_filter_exists(self, backend) -> None: - handlers = MailboxHandlers(backend, True) + handlers = MailboxHandlers(backend, b'testadmintoken') data = b'X-Foo: foo\nX-Bar: bar\n\ntest message!\n' - login = Login(authcid='testuser', secret='testpass') - request = AppendRequest(login=login, mailbox='INBOX', + request = AppendRequest(user='testuser', mailbox='INBOX', flags=['\\Flagged', '\\Seen'], when=1234567890, data=data) async with ChannelFor([handlers]) as channel: stub = MailboxStub(channel) - response = await stub.Append(request) + response = await stub.Append(request, metadata=self.metadata) assert 'Test 7' == response.mailbox async def test_append_filter_header(self, backend) -> None: - handlers = MailboxHandlers(backend, True) + handlers = MailboxHandlers(backend, b'testadmintoken') data = b'X-Caffeine: C8H10N4O2\n\ntest message!\n' - login = Login(authcid='testuser', secret='testpass') - request = AppendRequest(login=login, mailbox='INBOX', + request = AppendRequest(user='testuser', mailbox='INBOX', flags=['\\Flagged', '\\Seen'], when=1234567890, data=data) async with ChannelFor([handlers]) as channel: stub = MailboxStub(channel) - response = await stub.Append(request) + response = await stub.Append(request, metadata=self.metadata) assert 'Test 8' == response.mailbox async def test_append_filter_size(self, backend) -> None: - handlers = MailboxHandlers(backend, True) + handlers = MailboxHandlers(backend, b'testadmintoken') data = b'From: user@example.com\n\ntest message!\n' data = data + b'x' * (1234 - len(data)) - login = Login(authcid='testuser', secret='testpass') - request = AppendRequest(login=login, mailbox='INBOX', + request = AppendRequest(user='testuser', mailbox='INBOX', flags=['\\Flagged', '\\Seen'], when=1234567890, data=data) async with ChannelFor([handlers]) as channel: stub = MailboxStub(channel) - response = await stub.Append(request) + response = await stub.Append(request, metadata=self.metadata) assert 'Test 9' == response.mailbox diff --git a/test/server/test_admin_system.py b/test/server/test_admin_system.py new file mode 100644 index 00000000..cfc2573a --- /dev/null +++ b/test/server/test_admin_system.py @@ -0,0 +1,45 @@ + +import pytest # type: ignore +from grpclib.testing import ChannelFor +from pymapadmin.grpc.admin_grpc import SystemStub +from pymapadmin.grpc.admin_pb2 import SUCCESS, FAILURE, \ + LoginRequest, PingRequest + +from pymap import __version__ as pymap_version +from pymapadmin import __version__ as pymap_admin_version +from pymap.admin.handlers.system import SystemHandlers + +from .base import TestBase + +pytestmark = pytest.mark.asyncio + + +class TestSystemHandlers(TestBase): + + async def test_login(self, backend) -> None: + handlers = SystemHandlers(backend, b'') + request = LoginRequest(authcid='testuser', secret='testpass') + async with ChannelFor([handlers]) as channel: + stub = SystemStub(channel) + response = await stub.Login(request) + assert SUCCESS == response.result.code + assert response.bearer_token + + async def test_login_failure(self, backend) -> None: + handlers = SystemHandlers(backend, b'') + request = LoginRequest(authcid='baduser', secret='badpass') + async with ChannelFor([handlers]) as channel: + stub = SystemStub(channel) + response = await stub.Login(request) + assert FAILURE == response.result.code + assert 'InvalidAuth' == response.result.key + + async def test_ping(self, backend) -> None: + handlers = SystemHandlers(backend, b'') + request = PingRequest() + async with ChannelFor([handlers]) as channel: + stub = SystemStub(channel) + response = await stub.Ping(request) + assert SUCCESS == response.result.code + assert pymap_version == response.pymap_version + assert pymap_admin_version == response.pymap_admin_version diff --git a/test/server/test_admin_user.py b/test/server/test_admin_user.py new file mode 100644 index 00000000..3049111a --- /dev/null +++ b/test/server/test_admin_user.py @@ -0,0 +1,73 @@ + +import pytest # type: ignore +from grpclib.testing import ChannelFor +from pymapadmin.grpc.admin_grpc import UserStub +from pymapadmin.grpc.admin_pb2 import SUCCESS, FAILURE, \ + GetUserRequest, SetUserRequest, DeleteUserRequest, UserData + +from pymap.admin.handlers.user import UserHandlers + +from .base import TestBase + +pytestmark = pytest.mark.asyncio + + +class TestMailboxHandlers(TestBase): + + admin_token = 'MDAwZWxvY2F0aW9uIAowMDEwaWRlbnRpZmllciAKMDAxNWNpZCB0eXBl' \ + 'ID0gYWRtaW4KMDAyZnNpZ25hdHVyZSBTApt6-KNq85_1TeSmQyqTZjWPfHCYPY8EIG' \ + 'q6NMqv4go' + metadata = {'auth-token': admin_token} + + async def test_get_user(self, backend) -> None: + handlers = UserHandlers(backend, b'testadmintoken') + request = GetUserRequest(user='testuser') + async with ChannelFor([handlers]) as channel: + stub = UserStub(channel) + response = await stub.GetUser(request, metadata=self.metadata) + assert SUCCESS == response.result.code + assert 'testuser' == response.username + assert 'testpass' == response.data.password + + async def test_get_user_not_found(self, backend) -> None: + handlers = UserHandlers(backend, b'testadmintoken') + request = GetUserRequest(user='baduser') + async with ChannelFor([handlers]) as channel: + stub = UserStub(channel) + response = await stub.GetUser(request, metadata=self.metadata) + assert FAILURE == response.result.code + assert 'UserNotFound' == response.result.key + + async def test_set_user(self, backend, imap_server) -> None: + handlers = UserHandlers(backend, b'testadmintoken') + data = UserData(password='newpass', params={'key': 'val'}) + request = SetUserRequest(user='testuser', data=data) + async with ChannelFor([handlers]) as channel: + stub = UserStub(channel) + response = await stub.SetUser(request, metadata=self.metadata) + assert SUCCESS == response.result.code + assert 'testuser' == response.username + + transport = self.new_transport(imap_server) + transport.push_login(password=b'newpass') + transport.push_select(b'INBOX') + transport.push_logout() + await self.run(transport) + + async def test_delete_user(self, backend) -> None: + handlers = UserHandlers(backend, b'testadmintoken') + request = DeleteUserRequest(user='testuser') + async with ChannelFor([handlers]) as channel: + stub = UserStub(channel) + response = await stub.DeleteUser(request, metadata=self.metadata) + assert SUCCESS == response.result.code + assert 'testuser' == response.username + + async def test_delete_user_not_found(self, backend) -> None: + handlers = UserHandlers(backend, b'testadmintoken') + request = DeleteUserRequest(user='baduser') + async with ChannelFor([handlers]) as channel: + stub = UserStub(channel) + response = await stub.DeleteUser(request, metadata=self.metadata) + assert FAILURE == response.result.code + assert 'UserNotFound' == response.result.key diff --git a/test/server/test_managesieve.py b/test/server/test_managesieve.py index bbb7a99c..1a3b3897 100644 --- a/test/server/test_managesieve.py +++ b/test/server/test_managesieve.py @@ -230,7 +230,8 @@ async def test_checkscript(self, managesieve_server): b'checkscript {10+}\r\n' b'1234567890\r\n') transport.push_write( - b'NO "Unhandled parsing error"\r\n') + b'NO {67}\r\nline 1: parsing error: unexpected token ' + b"'1234567890' found near '1'\r\n") transport.push_readline( b'checkscript {19+}\r\n' b'else { discard; }\r\n\r\n')