import hashlib from functools import wraps import requests from sqlalchemy.sql.elements import or_ from jose import jwt from . import Resource from .. import api_errors from . import cognito from tracker import db, config, whitelist from api.resources import utils import flask import flask_login import hmac import base64 # Flask-Login works via a login manager. To kick things off, we'll set up the login manager by instantiating it # and telling it about our Flask app: login_manager = flask_login.LoginManager() def init_app(app): login_manager.init_app(app) ADMIN_USERNAMES = {"rob": True, "joel": True} regions = {"region": {"db_uri": config.db_connection_uri, "title": "Region"}} # The old login method is still supported to allow local non-internet connected tests to work. users = ( {"test": {"pw": "Test12345678", "role": "staff", "region": "region"}} if config.api_allow_local_login else {} ) # We also need to tell Flask-Login how to load a user from a Flask request and from its session. To do this # we need to define our user object, a user_loader callback, and a request_loader callback. class User(flask_login.UserMixin): def __init__(self, name): self.name = name self._db_user: db.User = None # Lazy fetched def __json__(self): return { "name": self.name, "anonId": self.anon_id(), "team": self.team, "charts": self.db_user.charts, "isAdmin": self.is_admin, "capabilities": self._get_capabilities(), "hiddenArtistKeys": self.db_user.hidden_artist_keys, "lastClearedAlerts": self.db_user.last_cleared_alerts.isoformat() if self.db_user.last_cleared_alerts else None, "scouts": self.get_scouts(), "regressionArtists": self.get_regression_artists(), "allCharts": self.get_all_charts(), "spyPlaylists": self.get_playlist_data(), "spyGenres": self.get_genres(), "ytChannels": self.get_youtube_channels(), } def anon_id(self): return hashlib.md5(self.name.encode('utf8')).hexdigest()[2:17] @property def db_user(self) -> db.User: if self._db_user is not None: return self._db_user self._setup_db_if_needed() self._db_user = db.Session.query(db.User).get(self.name) return self._db_user @property def team(self): return self.db_user.team def get_youtube_channels(self): # channels this user currently has selected. self._setup_db_if_needed() return list( db.execute_to_dicts( "select ch.ytid, ch.name from yt_channels ch join users_yt_channels u on ch.ytid = u.ytid and u.username = :username", params=dict(username=self.name), ) ) def get_all_charts(self): # Charts this user is aloowed to see. self._setup_db_if_needed() return ( db.Session.query(db.Chart) .filter( db.Chart.deleted_at.is_(None), or_(db.Chart.public.is_(True), db.Chart.created_by == self.name), ) .all() ) def get_playlist_data(self): self._setup_db_if_needed() return list( db.execute_to_dicts( """ select pl.spyid, pl.name, usp.spyid is not null as is_users from spy_playlist pl left join users_spy_playlists usp on usp.username = :username and usp.spyid = pl.spyid where pl.active and (pl.scrape_daily or usp.spyid is not null); """, params=dict(username=self.name), ) ) def get_genres(self): self._setup_db_if_needed() return [ r[0] for r in db.Session.execute( """ select genre from spy_genres """ ).fetchall() ] def _setup_db_if_needed(self): if not db.Session.registry.has(): db.setup_session(connection_uri=self.db_uri) def get_regression_artists(self): self._setup_db_if_needed() # circular import from api.models import regression_artists return {"sc": regression_artists(self.name)} def get_scouts(self): self._setup_db_if_needed() return { "sc": db.Session.query(db.UsersScInfluencers) .filter(db.UsersScInfluencers.username == self.name) .all(), "in": db.Session.query(db.UsersInInfluencers) .filter(db.UsersInInfluencers.username == self.name) .all(), "tw": db.Session.query(db.UsersTwInfluencers) .filter(db.UsersTwInfluencers.username == self.name) .all(), } def _get_capabilities(self): # this could eventually come from the DB if multiple roles becomes a thing return { "spotify": {"path": "/api/spotify"}, "spotify_tag": { "path": ["/api/spotify/tracks/", "", "/tags/", ""], "vars": {"track_spyid": 1, "tag": 3}, }, "youtube": {"path": "/api/youtube"}, "charts": {"path": "/api/charts"}, "my_whitelist": {"path": "/api/my_whitelist"}, "twitter_by_day": {"path": "/api/twitter_by_day"}, "soundcloud_by_day": {"path": "/api/soundcloud_by_day"}, "instagram_by_day": {"path": "/api/instagram_by_day"}, "like_track": {"path": "/api/likes/"}, "react_to_track": {"path": "/api/reactions/"}, "add_influencers": True, "manual_spider_artist": True, "spotify_stream_estimates": True, "teamWhitelists": self.has_team_whitelists(), "admin": self.is_admin, } def has_team_whitelists(self): return self.team in ['whitelist', 'sonycanada', 'sonyme', 'sonyaus'] def get_all_whitelists(self) -> whitelist.UserWhitelists: whitelists = [ whitelist.Whitelist(self.name, 'private') ] if self.has_team_whitelists() and self.team: whitelists.append(whitelist.Whitelist(self.team, 'team')) return whitelist.UserWhitelists(whitelists) @property def id(self): # UserMixin expects an id return self.name def has_capability(self, name): return name in self._get_capabilities() @property def db_uri(self): config.db_connection_uri @property def is_admin(self): return ADMIN_USERNAMES.get(self.name) @login_manager.user_loader def user_loader(name) -> User: return User(name) # We now have a basic working application that makes use of session-based authentication. # To round things off, we should provide a callback for login failures: @login_manager.unauthorized_handler def unauthorized_handler(): raise api_errors.UnauthenticatedError() def get_auth0_verified_payload(auth0_token): jsonurl = requests.get("https://" + config.api_auth0_domain + "/.well-known/jwks.json") jwks = jsonurl.json() unverified_header = jwt.get_unverified_header(auth0_token) rsa_key = {} for key in jwks["keys"]: if key["kid"] == unverified_header["kid"]: rsa_key = { "kty": key["kty"], "kid": key["kid"], "use": key["use"], "n": key["n"], "e": key["e"] } if rsa_key: try: return jwt.decode( auth0_token, rsa_key, algorithms=["RS256"], audience=(config.api_auth0_client_id), issuer="https://" + config.api_auth0_domain + "/" ) except jwt.ExpiredSignatureError: logger.warning("Got expired token") raise api_errors.SessionExpiredError("Access token is expired.") except jwt.JWTClaimsError as e: raise api_errors.UnauthenticatedError(f"Invalid claims in token {e}") except Exception as e: raise api_errors.BadRequestError("Failed to parse auth0token") else: raise api_errors.BadRequestError("No rsa key in auth0token") class Login(Resource): @flask_login.login_required def get(self): return flask_login.current_user def post(self): data = flask.request.json if isinstance(data, dict) and data.get('auth0Token'): payload = get_auth0_verified_payload(data['auth0Token']) if payload and payload.get('email'): db.setup_session(application_name='apollo_login') db_user = db.Session.query(db.User.username).filter(db.User.apollo_username == payload['email']).first() if db_user: user = _login_user(db_user[0], None, skip_pass_check=True) if user: return self.get() raise api_errors.NonAuthorisedApolloAccount() elif isinstance(data, dict) and data.get('accessToken'): details = cognito.get_user_details("eu-west-1", config.api_cognito_user_pool_id, data['accessToken']) user = _login_user(details.get("username"), None, skip_pass_check=True) if user: return self.get() elif config.api_allow_local_login: flask_login.logout_user() user = _login_user(data[0], data[1]) if user: return self.get() raise api_errors.IncorrectLoginError() def delete(self): flask_login.logout_user() return "" def _login_user(username, pw, skip_pass_check=False): if skip_pass_check: u = user_loader(username) flask_login.login_user(u) return u else: # Support for old local login method (only for testing) user_dict = users.get(username) if user_dict and hmac.compare_digest(user_dict["pw"], pw): u = User(username) flask_login.login_user(u) return u