From 192d633a13adf2d552f4257f4975b066204b9da9 Mon Sep 17 00:00:00 2001 From: Jason Gunthorpe Date: Thu, 28 May 2020 10:13:37 -0300 Subject: Add OAUTH Credential server The OAUTH credential server allows CMS to ack as an OAUTH broker and supply bearer tokens to other applications in the system. Currently this only support SMTP tokens for outbound mail delivery. A UNIX domain socket is used to communicate between the SMTP agent and CMS. A simple one line protocol is used to specify the account requested and CMS returns the plain XAOUTH2 response string. The agent is responsible to base64 encode it. This works for GMail and O365 mailboxes. Signed-off-by: Jason Gunthorpe --- cloud_mdir_sync/config.py | 5 ++++ cloud_mdir_sync/credsrv.py | 65 ++++++++++++++++++++++++++++++++++++++++++++ cloud_mdir_sync/gmail.py | 34 +++++++++++++++++------ cloud_mdir_sync/oauth.py | 25 +++++++++++++++++ cloud_mdir_sync/office365.py | 29 ++++++++++++++++---- 5 files changed, 143 insertions(+), 15 deletions(-) create mode 100644 cloud_mdir_sync/credsrv.py (limited to 'cloud_mdir_sync') diff --git a/cloud_mdir_sync/config.py b/cloud_mdir_sync/config.py index 88a16b6..79ab4a9 100644 --- a/cloud_mdir_sync/config.py +++ b/cloud_mdir_sync/config.py @@ -116,5 +116,10 @@ class Config(object): self.local_mboxes.append(MailDirMailbox(self, directory)) return self.local_mboxes[-1] + def CredentialServer(self, path: str, accounts: List, umask=0o600): + from .credsrv import CredentialServer + self.async_tasks.append(CredentialServer(self, path, accounts, umask)) + return self.async_tasks[-1] + def _direct_message(self, msg): return self.local_mboxes[0] diff --git a/cloud_mdir_sync/credsrv.py b/cloud_mdir_sync/credsrv.py new file mode 100644 index 0000000..97357bc --- /dev/null +++ b/cloud_mdir_sync/credsrv.py @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: GPL-2.0+ +import asyncio +import contextlib +import os +import re +from typing import List + +from . import config, oauth + + +class CredentialServer(object): + """Serve XOAUTH2 bearer tokens over a unix domain socket. The client + writes the user to obtain a token for and the server responds with the + token""" + def __init__(self, + cfg: config.Config, + path: str, + accounts: List[oauth.Account], + umask=0o600): + self.cfg = cfg + self.path = os.path.abspath(os.path.expanduser(path)) + self.umask = umask + self.accounts = {} + for I in accounts: + I.oauth_smtp = True + self.accounts[I.user] = I + + async def go(self): + old_umask = os.umask(self.umask) + try: + self.server = await asyncio.start_unix_server( + self.handle_client, self.path) + finally: + os.umask(old_umask) + os.chmod(self.path, self.umask) + + async def close(self): + pass + + async def handle_client(self, reader: asyncio.StreamReader, + writer: asyncio.StreamWriter) -> None: + with contextlib.closing(writer): + req = await reader.read() + g = re.match(r"([^ ,]+)(?:,(\S+))? (\S+@\S+)", req.decode()) + if g is None: + self.cfg.logger.error(f"Invalid credential request {req!r}") + return + proto, opts_str, user = g.groups() + if opts_str: + opts = opts_str.split(',') + else: + opts = [] + + self.cfg.logger.debug( + f"Credential request {proto!r} {opts} {user!r}") + + account = self.accounts.get(user) + if account is None: + return + + xoauth2 = await account.get_xoauth2_bytes("SMTP") + if xoauth2 is None: + return + writer.write(xoauth2) + await writer.drain() diff --git a/cloud_mdir_sync/gmail.py b/cloud_mdir_sync/gmail.py index b22291f..b6d1490 100644 --- a/cloud_mdir_sync/gmail.py +++ b/cloud_mdir_sync/gmail.py @@ -14,7 +14,7 @@ import aiohttp import oauthlib import requests_oauthlib -from . import config, mailbox, messages, util +from . import config, mailbox, messages, oauth, util from .util import asyncio_complete @@ -108,7 +108,7 @@ def _retry_protect(func): return async_wrapper -class GmailAPI(object): +class GmailAPI(oauth.Account): """An OAUTH2 authenticated session to the Google gmail API""" # From ziepe.ca client_id = "14979213351-bik90v3b8b9f22160ura3oah71u3l113.apps.googleusercontent.com" @@ -119,9 +119,8 @@ class GmailAPI(object): headers: Optional[Dict[str, str]] = None def __init__(self, cfg: config.Config, user: str): + super().__init__(cfg, user) self.domain_id = f"gmail-{user}" - self.cfg = cfg - self.user = user async def go(self): cfg = self.cfg @@ -130,18 +129,22 @@ class GmailAPI(object): self.session = aiohttp.ClientSession(connector=connector, raise_for_status=False) + scopes = [ + "https://www.googleapis.com/auth/gmail.modify", + ] + if self.oauth_smtp: + scopes.append("https://mail.google.com/") + self.redirect_url = cfg.web_app.url + "oauth2/gmail" self.api_token = cfg.msgdb.get_authenticator(self.domain_id) + if not oauth.check_scopes(self.api_token, scopes): + self.api_token = None self.oauth = requests_oauthlib.OAuth2Session( client_id=self.client_id, client=NativePublicApplicationClient(self.client_id), redirect_uri=self.redirect_url, token=self.api_token, - scope=[ - "https://www.googleapis.com/auth/gmail.modify", - # This one is needed for SMTP ? - #"https://mail.google.com/", - ]) + scope=scopes) if self.api_token: self._set_token() @@ -296,6 +299,19 @@ class GmailAPI(object): async def close(self): await self.session.close() + async def get_xoauth2_bytes(self, proto: str) -> bytes: + """Return the xoauth2 byte string for the given protocol to login to + this account.""" + while self.api_token is None: + await self.authenticate() + + if proto == "SMTP": + res = 'user=%s\1auth=%s %s\1\1' % (self.user, + self.api_token["token_type"], + self.api_token["access_token"]) + return res.encode() + return None + class GMailMessage(messages.Message): gmail_labels: Optional[Set[str]] = None diff --git a/cloud_mdir_sync/oauth.py b/cloud_mdir_sync/oauth.py index 163dcba..55f3f31 100644 --- a/cloud_mdir_sync/oauth.py +++ b/cloud_mdir_sync/oauth.py @@ -1,10 +1,35 @@ # SPDX-License-Identifier: GPL-2.0+ import asyncio import os +from abc import abstractmethod +from typing import TYPE_CHECKING, List import aiohttp import aiohttp.web +if TYPE_CHECKING: + from . import config + + +def check_scopes(token, required_scopes: List[str]) -> bool: + if token is None: + return False + tscopes = set(token.get("scope", [])) + return set(required_scopes).issubset(tscopes) + + +class Account(object): + """An OAUTH2 account""" + oauth_smtp = False + + def __init__(self, cfg: "config.Config", user: str): + self.cfg = cfg + self.user = user + + @abstractmethod + async def get_xoauth2_bytes(self, proto: str) -> bytes: + pass + class WebServer(object): """A small web server is used to manage oauth requests. The user should point a browser diff --git a/cloud_mdir_sync/office365.py b/cloud_mdir_sync/office365.py index 09d8492..4945724 100644 --- a/cloud_mdir_sync/office365.py +++ b/cloud_mdir_sync/office365.py @@ -12,7 +12,7 @@ from typing import Any, Dict, Optional, Union import aiohttp import requests -from . import config, mailbox, messages, util +from . import config, mailbox, messages, oauth, util from .util import asyncio_complete @@ -67,21 +67,20 @@ def _retry_protect(func): return async_wrapper -class GraphAPI(object): +class GraphAPI(oauth.Account): """An OAUTH2 authenticated session to the Microsoft Graph API""" graph_scopes = [ "https://graph.microsoft.com/User.Read", "https://graph.microsoft.com/Mail.ReadWrite" ] - graph_token = None + graph_token: Optional[Dict[str,str]] = None owa_scopes = ["https://outlook.office.com/mail.read"] owa_token = None authenticator = None def __init__(self, cfg: config.Config, user: str, tenant: str): + super().__init__(cfg, user) self.domain_id = f"o365-{user}-{tenant}" - self.cfg = cfg - self.user = user self.tenant = tenant if self.user is not None: @@ -93,7 +92,7 @@ class GraphAPI(object): # with our caching scheme. See # https://docs.microsoft.com/en-us/graph/outlook-immutable-id self.headers= {"Prefer": 'IdType="ImmutableId"'} - self.owa_headers = {} + self.owa_headers: Dict[str, str] = {} async def go(self): import msal @@ -111,6 +110,11 @@ class GraphAPI(object): authority=f"https://login.microsoftonline.com/{self.tenant}", token_cache=self.msl_cache) + if self.oauth_smtp: + self.owa_scopes = self.owa_scopes + [ + "https://outlook.office.com/SMTP.Send" + ] + def _cached_authenticate(self): accounts = self.msal.get_accounts(self.user) if len(accounts) != 1: @@ -352,6 +356,19 @@ class GraphAPI(object): async def close(self): await self.session.close() + async def get_xoauth2_bytes(self, proto: str) -> bytes: + """Return the xoauth2 byte string for the given protocol to login to + this account.""" + while self.owa_token is None: + await self.authenticate() + + if proto == "SMTP": + res = 'user=%s\1auth=%s %s\1\1' % (self.user, + self.owa_token["token_type"], + self.owa_token["access_token"]) + return res.encode() + return None + class O365Mailbox(mailbox.Mailbox): """Cloud Office365 mailbox using the Microsoft Graph RESET API for data access""" -- cgit v1.2.3