2016-08-19 09:19:06 +02:00
|
|
|
"""Authentication code common to the web and api modules."""
|
|
|
|
|
2017-08-18 13:19:34 +02:00
|
|
|
import collections
|
2016-08-19 09:19:06 +02:00
|
|
|
import logging
|
2017-05-12 13:38:54 +02:00
|
|
|
import typing
|
2016-08-19 09:19:06 +02:00
|
|
|
|
2017-08-24 13:56:18 +02:00
|
|
|
from flask import session, g
|
2016-08-19 09:19:06 +02:00
|
|
|
import flask_login
|
2017-05-12 13:41:18 +02:00
|
|
|
from werkzeug.local import LocalProxy
|
2016-08-19 09:19:06 +02:00
|
|
|
|
2017-08-22 11:31:17 +02:00
|
|
|
from pillar import current_app
|
|
|
|
|
2017-08-18 13:19:34 +02:00
|
|
|
import bson
|
|
|
|
|
2016-08-19 09:19:06 +02:00
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
2017-08-18 14:47:42 +02:00
|
|
|
# Mapping from user role to capabilities obtained by users with that role.
|
|
|
|
CAPABILITIES = collections.defaultdict(**{
|
|
|
|
'subscriber': {'subscriber', 'home-project'},
|
|
|
|
'demo': {'subscriber', 'home-project'},
|
|
|
|
'admin': {'subscriber', 'home-project', 'video-encoding', 'admin',
|
|
|
|
'view-pending-nodes', 'edit-project-node-types'},
|
|
|
|
}, default_factory=frozenset)
|
|
|
|
|
2016-08-19 09:19:06 +02:00
|
|
|
|
|
|
|
class UserClass(flask_login.UserMixin):
|
2017-05-12 13:38:54 +02:00
|
|
|
def __init__(self, token: typing.Optional[str]):
|
2016-08-19 09:19:06 +02:00
|
|
|
# We store the Token instead of ID
|
|
|
|
self.id = token
|
2017-05-12 13:38:54 +02:00
|
|
|
self.username: str = None
|
|
|
|
self.full_name: str = None
|
2017-08-18 13:19:34 +02:00
|
|
|
self.user_id: bson.ObjectId = None
|
2017-05-12 13:38:54 +02:00
|
|
|
self.objectid: str = None
|
|
|
|
self.gravatar: str = None
|
|
|
|
self.email: str = None
|
|
|
|
self.roles: typing.List[str] = []
|
2017-08-18 13:19:34 +02:00
|
|
|
self.groups: typing.List[str] = [] # NOTE: these are stringified object IDs.
|
|
|
|
self.group_ids: typing.List[bson.ObjectId] = []
|
2017-08-18 14:47:42 +02:00
|
|
|
self.capabilities: typing.Set[str] = set()
|
2017-08-18 13:19:34 +02:00
|
|
|
|
2017-08-24 12:35:31 +02:00
|
|
|
# Lazily evaluated
|
|
|
|
self._has_organizations: typing.Optional[bool] = None
|
|
|
|
|
2017-08-18 13:19:34 +02:00
|
|
|
@classmethod
|
|
|
|
def construct(cls, token: str, db_user: dict) -> 'UserClass':
|
|
|
|
"""Constructs a new UserClass instance from a Mongo user document."""
|
|
|
|
|
2017-08-29 11:34:39 +02:00
|
|
|
from ..api import utils
|
|
|
|
|
2017-08-24 12:35:31 +02:00
|
|
|
user = cls(token)
|
2017-08-18 13:19:34 +02:00
|
|
|
|
|
|
|
user.user_id = db_user['_id']
|
|
|
|
user.roles = db_user.get('roles') or []
|
|
|
|
user.group_ids = db_user.get('groups') or []
|
|
|
|
user.email = db_user.get('email') or ''
|
2017-08-24 13:56:18 +02:00
|
|
|
user.username = db_user.get('username') or ''
|
2017-08-18 13:19:34 +02:00
|
|
|
user.full_name = db_user.get('full_name') or ''
|
|
|
|
|
|
|
|
# Derived properties
|
|
|
|
user.objectid = str(db_user['_id'])
|
|
|
|
user.gravatar = utils.gravatar(user.email)
|
|
|
|
user.groups = [str(g) for g in user.group_ids]
|
2017-08-18 14:47:42 +02:00
|
|
|
user.collect_capabilities()
|
2017-08-18 13:19:34 +02:00
|
|
|
|
|
|
|
return user
|
|
|
|
|
2017-08-24 12:35:31 +02:00
|
|
|
def __repr__(self):
|
2017-08-18 19:14:29 +02:00
|
|
|
return f'UserClass(user_id={self.user_id})'
|
|
|
|
|
2017-08-18 13:19:34 +02:00
|
|
|
def __getitem__(self, item):
|
|
|
|
"""Compatibility layer with old dict-based g.current_user object."""
|
|
|
|
|
|
|
|
if item == 'user_id':
|
|
|
|
return self.user_id
|
|
|
|
if item == 'groups':
|
|
|
|
return self.group_ids
|
|
|
|
if item == 'roles':
|
|
|
|
return set(self.roles)
|
|
|
|
|
|
|
|
raise KeyError(f'No such key {item!r}')
|
|
|
|
|
|
|
|
def get(self, key, default=None):
|
|
|
|
"""Compatibility layer with old dict-based g.current_user object."""
|
|
|
|
|
|
|
|
try:
|
|
|
|
return self[key]
|
|
|
|
except KeyError:
|
|
|
|
return default
|
2016-08-19 09:19:06 +02:00
|
|
|
|
2017-08-18 14:47:42 +02:00
|
|
|
def collect_capabilities(self):
|
2017-08-22 11:31:17 +02:00
|
|
|
"""Constructs the capabilities set given the user's current roles.
|
|
|
|
|
|
|
|
Requires an application context to be active.
|
|
|
|
"""
|
|
|
|
|
|
|
|
app_caps = current_app.user_caps
|
2017-08-18 14:47:42 +02:00
|
|
|
|
2017-08-22 11:31:17 +02:00
|
|
|
self.capabilities = set().union(*(app_caps[role] for role in self.roles))
|
2017-08-18 14:47:42 +02:00
|
|
|
|
2016-08-19 09:19:06 +02:00
|
|
|
def has_role(self, *roles):
|
|
|
|
"""Returns True iff the user has one or more of the given roles."""
|
|
|
|
|
|
|
|
if not self.roles:
|
|
|
|
return False
|
|
|
|
|
|
|
|
return bool(set(self.roles).intersection(set(roles)))
|
|
|
|
|
2017-08-18 14:47:42 +02:00
|
|
|
def has_cap(self, *capabilities: typing.Iterable[str]) -> bool:
|
|
|
|
"""Returns True iff the user has one or more of the given capabilities."""
|
|
|
|
|
|
|
|
if not self.capabilities:
|
|
|
|
return False
|
|
|
|
|
|
|
|
return bool(set(self.capabilities).intersection(set(capabilities)))
|
|
|
|
|
2017-08-18 13:19:34 +02:00
|
|
|
def matches_roles(self,
|
|
|
|
require_roles=set(),
|
|
|
|
require_all=False) -> bool:
|
|
|
|
"""Returns True iff the user's roles matches the query.
|
|
|
|
|
|
|
|
:param require_roles: set of roles.
|
|
|
|
:param require_all:
|
|
|
|
When False (the default): if the user's roles have a
|
|
|
|
non-empty intersection with the given roles, returns True.
|
|
|
|
When True: require the user to have all given roles before
|
|
|
|
returning True.
|
|
|
|
"""
|
|
|
|
|
|
|
|
if not isinstance(require_roles, set):
|
|
|
|
raise TypeError(f'require_roles param should be a set, but is {type(require_roles)!r}')
|
|
|
|
|
|
|
|
if require_all and not require_roles:
|
|
|
|
raise ValueError('require_login(require_all=True) cannot be used with '
|
|
|
|
'empty require_roles.')
|
|
|
|
|
|
|
|
intersection = require_roles.intersection(self.roles)
|
|
|
|
if require_all:
|
|
|
|
return len(intersection) == len(require_roles)
|
|
|
|
|
|
|
|
return not bool(require_roles) or bool(intersection)
|
|
|
|
|
2017-08-24 12:35:31 +02:00
|
|
|
def has_organizations(self) -> bool:
|
|
|
|
"""Returns True iff this user administers or is member of any organization."""
|
|
|
|
|
|
|
|
if self._has_organizations is None:
|
|
|
|
assert self.user_id
|
|
|
|
self._has_organizations = current_app.org_manager.user_has_organizations(self.user_id)
|
|
|
|
|
|
|
|
return bool(self._has_organizations)
|
|
|
|
|
2016-08-19 09:19:06 +02:00
|
|
|
|
2017-05-12 13:39:11 +02:00
|
|
|
class AnonymousUser(flask_login.AnonymousUserMixin, UserClass):
|
|
|
|
def __init__(self):
|
|
|
|
super().__init__(token=None)
|
2016-09-08 12:03:51 +02:00
|
|
|
|
2016-08-19 09:19:06 +02:00
|
|
|
def has_role(self, *roles):
|
|
|
|
return False
|
|
|
|
|
2017-08-18 14:47:42 +02:00
|
|
|
def has_cap(self, *capabilities):
|
|
|
|
return False
|
2016-08-19 09:19:06 +02:00
|
|
|
|
2017-08-24 12:35:31 +02:00
|
|
|
def has_organizations(self) -> bool:
|
|
|
|
return False
|
|
|
|
|
2017-08-18 13:19:34 +02:00
|
|
|
|
|
|
|
def _load_user(token) -> typing.Union[UserClass, AnonymousUser]:
|
2016-08-19 09:19:06 +02:00
|
|
|
"""Loads a user by their token.
|
|
|
|
|
|
|
|
:returns: returns a UserClass instance if logged in, or an AnonymousUser() if not.
|
|
|
|
"""
|
|
|
|
|
2017-08-29 11:34:39 +02:00
|
|
|
from ..api.utils import authentication
|
|
|
|
|
2017-09-13 15:23:38 +02:00
|
|
|
if not token:
|
|
|
|
return AnonymousUser()
|
|
|
|
|
2016-08-19 09:19:06 +02:00
|
|
|
db_user = authentication.validate_this_token(token)
|
|
|
|
if not db_user:
|
2017-09-13 15:23:38 +02:00
|
|
|
# There is a token, but it's not valid. We should reset the user's session.
|
|
|
|
session.clear()
|
2016-08-19 09:19:06 +02:00
|
|
|
return AnonymousUser()
|
|
|
|
|
2017-08-18 13:19:34 +02:00
|
|
|
user = UserClass.construct(token, db_user)
|
2016-08-19 09:19:06 +02:00
|
|
|
|
2017-08-18 13:19:34 +02:00
|
|
|
return user
|
2016-08-19 09:19:06 +02:00
|
|
|
|
|
|
|
|
|
|
|
def config_login_manager(app):
|
|
|
|
"""Configures the Flask-Login manager, used for the web endpoints."""
|
|
|
|
|
|
|
|
login_manager = flask_login.LoginManager()
|
|
|
|
login_manager.init_app(app)
|
|
|
|
login_manager.login_view = "users.login"
|
2017-05-05 10:40:08 +02:00
|
|
|
login_manager.login_message = ''
|
2016-08-19 09:19:06 +02:00
|
|
|
login_manager.anonymous_user = AnonymousUser
|
|
|
|
# noinspection PyTypeChecker
|
|
|
|
login_manager.user_loader(_load_user)
|
|
|
|
|
|
|
|
return login_manager
|
|
|
|
|
|
|
|
|
2017-07-13 17:08:43 +02:00
|
|
|
def login_user(oauth_token: str, *, load_from_db=False):
|
2016-09-08 12:03:51 +02:00
|
|
|
"""Log in the user identified by the given token."""
|
|
|
|
|
2017-08-18 13:19:34 +02:00
|
|
|
from flask import g
|
|
|
|
|
2017-05-12 13:40:59 +02:00
|
|
|
if load_from_db:
|
|
|
|
user = _load_user(oauth_token)
|
|
|
|
else:
|
|
|
|
user = UserClass(oauth_token)
|
2017-11-07 23:18:46 +01:00
|
|
|
flask_login.login_user(user, remember=True)
|
2017-08-18 13:19:34 +02:00
|
|
|
g.current_user = user
|
2016-09-08 12:03:51 +02:00
|
|
|
|
|
|
|
|
2017-10-17 12:16:56 +02:00
|
|
|
def get_blender_id_oauth_token() -> str:
|
|
|
|
"""Returns the Blender ID auth token, or an empty string if there is none."""
|
2017-05-05 10:29:16 +02:00
|
|
|
|
|
|
|
from flask import request
|
|
|
|
|
|
|
|
token = session.get('blender_id_oauth_token')
|
|
|
|
if token:
|
2017-10-17 12:40:33 +02:00
|
|
|
if isinstance(token, (tuple, list)):
|
|
|
|
# In a past version of Pillar we accidentally stored tuples in the session.
|
|
|
|
# Such sessions should be actively fixed.
|
|
|
|
# TODO(anyone, after 2017-12-01): refactor this if-block so that it just converts
|
|
|
|
# the token value to a string and use that instead.
|
|
|
|
token = token[0]
|
|
|
|
session['blender_id_oauth_token'] = token
|
2017-05-05 10:29:16 +02:00
|
|
|
return token
|
|
|
|
|
2017-10-17 12:16:56 +02:00
|
|
|
if request.authorization and request.authorization.username:
|
|
|
|
return request.authorization.username
|
2017-05-05 10:29:16 +02:00
|
|
|
|
2017-10-17 12:16:56 +02:00
|
|
|
return ''
|
2016-08-19 09:19:06 +02:00
|
|
|
|
|
|
|
|
2017-08-29 11:34:39 +02:00
|
|
|
def get_current_user() -> UserClass:
|
2017-08-24 13:56:18 +02:00
|
|
|
"""Returns the current user as a UserClass instance.
|
2017-05-12 13:41:18 +02:00
|
|
|
|
2017-08-24 13:56:18 +02:00
|
|
|
Never returns None; returns an AnonymousUser() instance instead.
|
2017-08-29 11:34:39 +02:00
|
|
|
|
|
|
|
This function is intended to be used when pillar.auth.current_user is
|
|
|
|
accessed many times in the same scope. Calling this function is then
|
|
|
|
more efficient, since it doesn't have to resolve the LocalProxy for
|
|
|
|
each access to the returned object.
|
2017-08-24 13:56:18 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
from ..api.utils.authentication import current_user
|
|
|
|
|
|
|
|
return current_user()
|
2017-05-12 13:41:18 +02:00
|
|
|
|
|
|
|
|
2017-08-29 11:34:39 +02:00
|
|
|
current_user: UserClass = LocalProxy(get_current_user)
|
2017-08-24 13:56:18 +02:00
|
|
|
"""The current user."""
|