예제 #1
0
class Server:
    def __init__(self, config: typing.Optional[Config] = None):
        """Grant host db role credentials to users by email verification.

        A server gets requests for auth tokens from clients. First, a client
        sends an email address to the server endpoint. Then, the server
        generates a one-time link and sends the link to the email address. Next,
        the client opens the link and stored the revealed auth token to its
        config. Finally, the client uses the server endpoint and token to
        request credentials.

        A server config stores (1) readWrite credentials for a db where it can
        manage tokens, credential grants to clients, and allow/deny rules; (2)
        config for sending mail; and (3) host admin credentials to manage
        user creation/deletion for dbs on those hosts.

        Args:
            config (Config): server configuration
        """
        self.cfg = config
        if config is None:
            try:
                self.cfg = Config(check=check, path=path)
            except ConfigError:
                self.cfg = Config(check=check, path=path, seed=seed())
        # XXX Tried combining @property and @lru_cache decorators for mgdb
        # et al., but couldn't call e.g. self.mgdb.cache_clear(), so using
        # private props for caching.
        self._mgdb = None
        self._mailer = None
        self._admin_client = {}

    def set_mgdb(self, uri):
        """Set mongogrant database URI.

        Args:
            uri (str): MongoDB URI string
        """
        config = self.cfg.load()
        config["mgdb_uri"] = uri
        self.cfg.save(config)
        self._mgdb = None

    @property
    def mgdb(self):
        """mongogrant database.

        Returns:
            pymongo.database.Database: server database
        """
        if self._mgdb is None:
            config = self.cfg.load()
            uri = config["mgdb_uri"]
            dbname = uri.split('/')[-1]
            self._mgdb = MongoClient(config["mgdb_uri"], connect=False)[dbname]
        return self._mgdb

    def set_mailer(self, cls, kwargs):
        """Set class with send(message) method and kwargs to init class.

        Class cls will be instantiated as cls(**kwargs). cls instance must have
        `send` method that takes {to,subject,text} args and sends an
        email to `to` with subject `subject` and body text `text`.

        Args:
            cls: mailer class
            kwargs: kwargs passed to created an instance of cls
        """
        config = self.cfg.load()
        config["mailer"]["class"] = "{}.{}".format(cls.__module__, cls.__name__)
        config["mailer"]["kwargs"] = kwargs
        self.cfg.save(config)
        self._mailer = None

    @property
    def mailer(self):
        """Mailer for email verification and delivering links to tokens.

        Returns:
            Mailer: something that can `send(to,subject,text)`
        """
        if self._mailer is None:
            mailconf = self.cfg.load()["mailer"]
            modname = ".".join(mailconf["class"].split('.')[:-1])
            clsname = mailconf["class"].split('.')[-1]
            cls = getattr(
                __import__(modname, globals(), locals(), [clsname], 0),
                clsname)
            self._mailer = cls(**mailconf["kwargs"])
        return self._mailer

    def set_admin_client(self, host: str, username: str, password: str):
        """Sets admin client auth info in config.

        Args:
            host (str): MongoDB host
            username (str): admin username
            password (str): admin password
        """
        config = self.cfg.load()
        config["auth"][host] = (
            "mongodb://{u}:{p}@{h}/{d}"
            .format(u=username, p=password, h=host, d="admin"))
        self.cfg.save(config)
        self._admin_client.pop(host, None)

    def admin_client(self, host):
        """Return host MongoClient authenticated as admin.

        Args:
            host (str): MongoDB host

        Returns:
            pymongo.mongo_client.MongoClient: host MongoClient as admin

        Raises:
            ConfigError: if authentication fails or cannot connect to host.
        """
        if host not in self._admin_client:
            config = self.cfg.load()
            try:
                client = MongoClient(config["auth"][host], connect=False,
                                     serverSelectionTimeoutMS=5000)
                client.server_info()
                self._admin_client[host] = client
            except pymongo.errors.OperationFailure as e:
                raise ConfigError("Auth error: {}".format(e))
            except pymongo.errors.ServerSelectionTimeoutError as e:
                return ConfigError("Cannot connect: {}".format(e))
        return self._admin_client[host]

    def set_rule(self, email: str, host: str, db: str, role: str,
                 which="allow"):
        """Allow/deny granting credentials for role on host db to email owner.

        Args:
            email (str): email address
            host (str): host name
            db (str): database name
            role (str): "read" or "readWrite"
            which (str): "allow" or "deny"
        """
        if role not in ("read", "readWrite"):
            raise ValueError("role must be one of {'read','readWrite'}")
        if which not in ("allow", "deny"):
            raise ValueError("which must be one of {'allow','deny'}")
        self.mgdb[which].update_one(dict(email=email, host=host, role=role),
                                    {"$addToSet": {"dbs": db}}, upsert=True)

    def get_ruler(self, token: str):
        """Get the spec for which allow/deny rules this token's owner can set.

        Args:
            token (str): fetch token

        Returns:
            bool: The ruler doc if the owner can set rules, None o/w
        """
        email = self.email_from_fetch_token(token)
        if email is None:
            return None
        return self.mgdb.rulers.find_one({"email": email})

    def can_grant(self, email: str, host: str, db: str, role: str):
        """Can the server grant credentials for role on host db to email owner?

        An email owner (with a token for that email) may obtain user/pass for
        role on host db if and only if both:
        (1) there is no deny rule that matches all of (email,host,db,role) or a
        subset, i.e. a deny rule for a "read" role also denies a "readWrite"
        role, and
        (2) there is an allow rule that matches all of (email,host,db,role) or a
        superset, i.e. an allow rule for a "readWrite" role also allows a "read"
        role.

        Args:
            email (str): email address
            host (str): host name
            db (str): database name
            role (str): "read" or "readWrite"

        Returns:
            bool: Whether server can grant requested credentials.
        """
        config = self.cfg.load()
        if host not in config["auth"]:
            return False

        base_filter = dict(email=email, host=host, dbs=db, role={"$in": [role]})
        allow_filter = deepcopy(base_filter)
        deny_filter = deepcopy(base_filter)
        if role == "read":
            allow_filter["role"]["$in"].append("readWrite")
        elif role == "readWrite":
            deny_filter["role"]["$in"].append("read")
        else:
            raise ValueError("role must be one of {'read','readWrite'}")
        return (not (self.mgdb.deny.count(deny_filter, limit=1) > 0) and
                self.mgdb.allow.count(allow_filter, limit=1) > 0)

    def grant(self, email: str, host: str, db: str, role: str):
        """Grant and return credentials for role on host db to email owner.

        Args:
            email (str): email address
            host (str): host name
            db (str): database name
            role (str): "read" or "readWrite"

        Returns:
            dict: {username,password} for role on host db if can grant, None
                otherwise.
        """
        if not self.can_grant(email, host, db, role):
            return None
        grant_filter = dict(email=email, host=host, db=db, role=role)
        command = ("updateUser" if self.mgdb.grants.count(grant_filter)
                   else "createUser")
        d = self.admin_client(host)[db]
        username = "******".format("_".join(email.split('@')), role)
        password = passphrase()
        try:
            d.command(command, username, pwd=password, roles=[role])
        except OperationFailure as e:
            if str(e) == "User {}@{} not found".format(username, db):
                # User erroneously in self.mgdb.grants collection
                command = "createUser"
                d.command(command, username, pwd=password, roles=[role])
        except DuplicateKeyError as e:
            # Trying to create user that already exists on db
            # and is not known to mongogrant. Leave alone.
            print(str(e))
            return None
        self.mgdb.grants.update_one(
            grant_filter, {"$set": dict(username=username)}, upsert=True)
        return dict(username=username, password=password)

    def revoke_grants(self, email: str,
                      host: str = "*", db: str = "*", role: str = "*"):
        """Revoke credential grants to email owner. Drop user(s) on host db(s).

        Any of host,db,role can be "*", i.e. a wildcard. For example, if host is
        "hostname" and db is "*" and role is "readWrite", revoke any readWrite
        credential grants for any db on "hostname".

        Args:
            email (str): email address
            host (str): host name, or "*" for all hosts
            db (str): database name, or "*" for all dbs
            role (str): "read", "readWrite" or "*" for all roles
        """
        grant_filter = dict(email=email, host=host, db=db, role=role)
        for field in ("host", "db", "role"):
            if grant_filter[field] == "*":
                grant_filter.pop(field)
        for doc in self.mgdb.grants.find(grant_filter):
            db = self.admin_client(doc["host"])[doc["db"]]
            db.command("dropUser", doc["username"])
        self.mgdb.grants.delete_many(grant_filter)

    def generate_tokens(self, email: str,
                        link_expires: str = "3 d", fetch_expires: str = "30 d"):
        """Generate link (to confirm email) and fetch (to grant auth) tokens.

        Generate tokens if there is at least one allow rule for email.

        Args:
            email (str): email address.
            link_expires (str): When, from now, link token expires. Format is
                "<n> <u>", where <u> is one of "s", "m", "h", or "d" for
                seconds, minutes, hours, or days, respectively. <n> is a number
                that will be cast to a float, so it can be e.g. "0.5".
            fetch_expires (str): When, from link_expires, fetch token expires.
                Format is same as link_expires.

        Returns:
            bool: True if tokens generated, False o/w.
        """
        if self.mgdb.allow.count(dict(email=email), limit=1) == 0:
            return False
        unit = dict(s="seconds", m="minutes", h="hours", d="days")
        n, u = link_expires.split(" ")
        link_expires = datetime.utcnow() + timedelta(**{unit[u]: float(n)})
        n, u = fetch_expires.split(" ")
        fetch_expires = link_expires + timedelta(**{unit[u]: float(n)})
        self.mgdb.tokens.update_one(
            dict(email=email),
            {"$push": {"fetch": dict(token=uuid4().hex, expires=fetch_expires),
                       "link": dict(token=uuid4().hex, expires=link_expires)}},
            upsert=True)
        self.delete_expired_tokens()
        return True

    def delete_expired_tokens(self):
        """Delete expired tokens. Also, remove docs with no tokens."""
        now = datetime.utcnow()
        bulk_requests = []
        docs = list(self.mgdb.tokens.find(
            {"$or": [{"link.expires": {"$lte": now}},
                     {"fetch.expires": {"$lte": now}},
                     {"link": [], "fetch": []}]}))
        for d in docs:
            if not d["link"] and not d["fetch"]:
                bulk_requests.append(DeleteOne(dict(_id=d["_id"])))
                continue
            link = []
            for t in d["link"]:
                if t["expires"] > now:
                    link.append(t)
            fetch = []
            for t in d["fetch"]:
                if t["expires"] > now:
                    fetch.append(t)
            if not link and not fetch:
                bulk_requests.append(DeleteOne(dict(_id=d["_id"])))
            else:
                bulk_requests.append(ReplaceOne(
                    dict(_id=d["_id"]),
                    dict(email=d["email"], link=link, fetch=fetch)
                ))
        if bulk_requests:
            self.mgdb.tokens.bulk_write(bulk_requests)

    def email_from_fetch_token(self, token: str):
        """Retrieve email given fetch token if not expired.

        Args:
            token (str): fetch token

        Returns:
            str: email address if fetch token not expired, None o/w.
        """
        doc = self.mgdb.tokens.find_one({
            "fetch": {"$elemMatch": {"token": token,
                                     "expires": {"$gte": datetime.utcnow()}}}
        }, ["email"])
        if not doc:
            return None
        return doc["email"]

    def send_link_token_mail(self, email: str, secure: bool = False,
                             host: str = "localhost", dry_run: bool = False):
        """Send email to deliver fetch token via one-time link.

        Args:
            email (str): email address.
            secure (bool): whether link should use https or not.
            host (str): server host including port if not :80.
            dry_run (bool): whether to send mail or to return message text.

        Returns:
            str: "OK" if email sent, message text if dry_run, error message o/w.
        """
        generated = self.generate_tokens(email)
        if not generated:
            return ("Email {} not allowed by server. Contact server admin."
                    .format(email))
        doc = self.mgdb.tokens.find_one(dict(email=email), ["link"])
        if not doc:
            return ("Email {} allowed by server, but "
                    "error in generating token. Contact server admin")
        doc["link"].sort(key=lambda t: t["expires"])
        link_token = doc["link"][-1]["token"]
        link = "{}://{}/verifytoken/{}".format(
            "https" if secure else "http", host, link_token)
        text = ("Retrieve your mongogrant fetch token by opening this "
                "one-time link: {}".format(link))
        subject = "Mongogrant fetch token from {}".format(host)
        if not dry_run:
            self.mailer.send(to=email, subject=subject, text=text)
            return "OK"
        else:
            return text

    def fetch_token_from_link(self, link_token: str):
        """Retrieve fetch token given link token, and remove link token.

        Args:
            link_token (str): received via email link

        Returns:
            str: message with fetch token if link token is valid,
                error message o/w.
        """
        now  = datetime.utcnow()
        doc = self.mgdb.tokens.find_one({
            "link": {"$elemMatch": {"token": link_token,
                                    "expires": {"$gte": now}}}
        }, ["fetch"])
        if not doc:
            return "Link tokens expire. Request again."

        fetch = sorted(doc["fetch"], key=lambda t: t["expires"])[-1]
        self.mgdb.tokens.update_one(
            dict(_id=doc["_id"]), {"$pull": {"link": {"token": link_token}}})
        return "Fetch token: {} (expires {} UTC)".format(
            fetch["token"], fetch["expires"])

    def grant_with_token(self, token: str, host: str, db: str, role: str):
        """Attempt to grant user/pass for role on host db given token.

        Args:
            token (str): fetch token
            host (str): host name
            db (str): database name
            role (str): "read" or "readWrite"

        Returns:
            dict: {username,password} for role on host db if can grant, None
                otherwise.
        """
        email = self.email_from_fetch_token(token)
        if not email:
            return None
        return self.grant(email, host, db, role)
예제 #2
0
 def test_save(self):
     cfg = Config(self.check, self.config_path, self.good_seed)
     config = cfg.load()
     config["req_list_field"].append(3)
     cfg.save(config)
     self.assertEqual(cfg.load()["req_list_field"][-1], 3)
예제 #3
0
class Client:
    def __init__(self, config: typing.Optional[Config] = None):
        """Get/set config for remotes, aliases, and auth.

        Args:
            config (Config): client configuration
        """
        self.cfg = config
        if config is None:
            try:
                self.cfg = Config(check=check, path=path)
            except ConfigError:
                self.cfg = Config(check=check, path=path, seed=seed())

    def set_remote(self, endpoint: str, token: str):
        """Set endpoint URL and auth token to retrieve database credentials.

        Args:
            endpoint (str): URL of server endpoint
            token (str): used to authenticate with server
        """
        config = self.cfg.load()
        rems = config["fetch"]["remotes"]
        for r in rems:
            if r["endpoint"] == endpoint:
                r["token"] = token
                break
        else:
            if endpoint.endswith("/"):
                endpoint = endpoint[:-1]
            rems.append(dict(endpoint=endpoint, token=token))
        self.cfg.save(config)

    def remotes(self):
        """Get list of remotes.

        Returns:
            list: of dicts, each with `endpoint` and `token` keys.
        """
        return self.cfg.load()["fetch"]["remotes"]

    def set_alias(self, alias: str, actual: str, which="host"):
        """Set alias for host or db so that e.g. FQDNs needn't be hard-coded.

        Args:
            alias (str): Nickname for host or db, e.g. "dev", "prod", etc.
            actual (str): Actual name of host or db, e.g. "server.example.com".
            which (str): "host" or "db".
        """
        config = self.cfg.load()
        config["fetch"]["{}_aliases".format(which)][alias] = actual
        self.cfg.save(config)

    def aliases(self, which="host"):
        """Get mapping of host_aliases or db_aliases (key->alias, value->actual).

        Args:
            which (str): "host" or "db"

        Returns:
            dict: mapping of alias to actual value.
        """
        return self.cfg.load()["fetch"]["{}_aliases".format(which)]

    @classmethod
    def check_auth(cls, auth: dict):
        """Check auth info for host db role credentials.

        Args:
            auth (dict): config spec

        Raises:
            AuthError: If authentication fails, if read role can write, or if
                readWrite role cannot write.
        """
        kwargs = dict(
            host=auth["host"],
            username=auth["username"],
            password=auth["password"],
            authSource=auth["db"],
        )
        client = MongoClient(**kwargs)
        db = client[auth["db"]]
        cname = "test_{}".format(uuid4())
        try:
            db[cname].insert_one({"test": 1})
            db.drop_collection(cname)
            if auth["role"] == "read":
                raise AuthError("Read-only user can write to database")
        except pymongo.errors.OperationFailure:
            if auth["role"] == "readWrite":
                raise AuthError("Read-write user cannot write to database")

    def set_auth(self,
                 host: str,
                 db: str,
                 role: str,
                 username: str,
                 password: str,
                 check=False):
        """Set auth info for role (read-only or read-write) on host db.

        Args:
            host (str): Host name, e.g. "my.server.com", "my.server.com:27018".
                Port 27017 is assumed unless otherwise specified.
            db (str): Database name on host.
            role (str): one of {"read", "readWrite"}.
            username (str): User for host db.
            password (str): Password for host db.
            check (bool): Whether to validate the auth info, i.e. try to connect
                to db and verify role.
        Raises:
            AuthError: If authentication fails, if read role can write, or if
                readWrite role cannot write.
        """
        auth = dict(host=host,
                    db=db,
                    role=role,
                    username=username,
                    password=password)
        if check:
            self.check_auth(auth)
        config = self.cfg.load()
        for a in config["auth"]:
            if a["host"] == host and a["db"] == db and a["role"] == role:
                a["username"] = username
                a["password"] = password
                break
        else:
            config["auth"].append(auth)
        self.cfg.save(config)

    def get_auth(self, host: str, db: str, role: str, as_uri=False):
        """Get auth credentials for role on host db.

        Args:
            host (str): Host name, e.g. "my.server.com", "my.server.com:27018".
                Port 27017 is assumed unless otherwise specified. Can also pass
                a host alias.
            db (str): Database name on host. Can also pass a db alias.
            role (str): one of {"read", "readWrite"} or aliases {"ro", "rw"}.
            as_uri (bool): format return value as MongoDB URI string.

        Returns:
            dict: of {host,db,role,user,password}, or MongoDB URI of type str,
                if auth entry exists. Returns None otherwise.
        """
        config = self.cfg.load()
        hosts, dbs = self.aliases("host"), self.aliases("db")
        if host in hosts:
            host = hosts[host]
        if db in dbs:
            db = dbs[db]
        if role == "ro":
            role = "read"
        elif role == "rw":
            role = "readWrite"
        elif role not in {"read", "readWrite"}:
            raise ValueError("role not in {'read', 'readWrite'}")
        for a in config["auth"]:
            if a["host"] == host and a["db"] == db and a["role"] == role:
                if as_uri:
                    return (
                        "mongodb://{username}:{password}@{host}/{db}".format(
                            **a))
                else:
                    return a
        print("No credentials for {}:{}/{} found in local config".format(
            role, host, db))
        for remote in self.remotes():
            print("Requesting credentials from {}".format(remote["endpoint"]))
            url = "{endpoint}/grant/{token}".format(**remote)
            rv = requests.post(url, dict(host=host, db=db, role=role))
            if rv.status_code == 200:
                d = rv.json()
                print("Found credentials. Saving to local config...")
                self.set_auth(host,
                              db,
                              role,
                              d["username"],
                              d["password"],
                              check=True)
                return self.get_auth(host, db, role)
            else:
                print("{}".format(rv.json()))
        return None

    def db(self, spec: str, **mongoclient_kwargs):
        """Get a pymongo Database object from a spec "<role>:<host>/<db>."

        Args:
            spec (str): of the format <role>:<host>/<db>, where: role is one of
                {"read", "readWrite"} or aliases {"ro", "rw"}; host is a db host
                (w/ optional port) or alias; and db is a db on that host,
                or alias.
            mongoclient_kwargs (dict): Extra keyword arguments to pass to
                invocation of MongoClient.

        Returns:
            pymongo.database.Database: from spec

        Raises:
            AuthError: If no valid auth credentials are available from local
                config or via remotes to connect to database.
        """
        auth = self.get_db_auth_from_spec(spec)
        return MongoClient(
            **dict(**auth, **mongoclient_kwargs))[auth["authSource"]]

    def get_db_auth_from_spec(self, spec: str):
        """Read the Mongo authentication information from a spec "<role>:<host>/<db>."
        Args:
            spec (str): of the format <role>:<host>/<db>, where: role is one of
                {"read", "readWrite"} or aliases {"ro", "rw"}; host is a db host
                (w/ optional port) or alias; and db is a db on that host,
                or alias.
        Returns:
            dict: authentication information from spec

        """
        role, host_db = spec.split(':', 1)
        host, dbname_or_alias = host_db.split('/', 1)
        auth = self.get_auth(host, dbname_or_alias, role)
        if auth is None:
            raise AuthError(
                "No valid auth credentials are accessible, either from "
                "local config or via remotes, to connect to "
                "database.")
        auth = auth.copy()
        dbname = auth["db"]
        auth["authSource"] = dbname
        auth.pop("db")
        auth.pop("role")
        return auth