from posixpath import join import jwt import structlog from flask import ( g, request, current_app, abort, redirect, url_for, has_request_context, ) from atlas_um import consts from atlas_um.audit import admin_audit from atlas_um.tokens.bearer_tokens import DNABearerToken from atlas_um.consts import SystemEvents from atlas_um.logs import logger from . import models, utils, claims class AuthManager: """ Flask extension to manage auth process. Wires up into request-response cycle. """ def __init__(self, app=None): self.app = app if app is not None: self.init_app(app) def init_app(self, app): app.auth_manager = self app.after_request(self._load_user) def _load_user(self, response=None): """ Hook to load user from cookie token. Makes signature verification and decoding. """ # check if already processed from utils.current_user if has_request_context() and hasattr(g, "user"): return response anonymous_user = models.AnonymousUser() token = self._get_token() public_key = current_app.config["DNA_IDENTITY_PUBLIC_KEY"] if not token: self._update_request_context_with_user(anonymous_user) return response try: claimset = jwt.decode( token, public_key, DNABearerToken.ENCODING_ALGORITHM, **self._get_validation_params(token), ) except jwt.PyJWTError as e: logger.bind(error=e).warning("Token decoding error") self._update_request_context_with_user(anonymous_user) return response if not isinstance(claimset, dict): logger.warning("Corrupted claimset") self._update_request_context_with_user(anonymous_user) return response sub = claimset.get("sub") if not sub: logger.warning("Empty sub in valid token") self._update_request_context_with_user(anonymous_user) return response user = models.User(sub, self._get_assigned_claims(claimset)) self._update_request_context_with_user(user) return response def _get_token(self): """ Fetch token from one of the following sources: - cookie - auth header - dev token param (e.g. for test automation needs, if active) """ if current_app.config.get("DEV_TOKEN_ENABLED") and request.args.get( consts.DEV_TOKEN_PARAM ): token = request.args.get(consts.DEV_TOKEN_PARAM) else: token = request.cookies.get( current_app.config.get("DNA_BEARER_TOKEN_COOKIE_NAME") ) or request.headers.get("Authorization", "").replace( "Bearer ", "" ) return token def _get_validation_params(self, token): """ We may have 2 kinds of tokens: - generic - without 'aud' claim, suitable for usage with all projects in a scope of end user authorization - m2m - with 'aud' claim, suitable for projects specified in 'aud' in a scope of server to server authorization This function returns params to pass the validation for both cases. We need this, as pyjwt lib has hard validation of `aud` claim on token decoding. """ params = {} try: claimset = jwt.decode(token, options={"verify_signature": False}) except jwt.PyJWTError as e: logger.bind(error=e).warning("Token decoding error") return params if "aud" in claimset: params["audience"] = claims.Audience.Values.list() return params def _get_assigned_claims(self, claimset): """Get claims, related to current application.""" assigned_claims = [] for claim in claims.registered_claims: full_path = ( claim.path if claim.is_reserved else join( current_app.config["RELATED_CLAIMS_NAMESPACE"], claim.path ) ) for key, value in claimset.items(): if full_path != key: continue # appending claims for allowed values try: assigned_claims.append(claim.from_token_value(value)) except ValueError: pass return assigned_claims def _update_request_context_with_user(self, user): g.user = user structlog.threadlocal.bind_threadlocal(user=user) def unauthorized(self, login_redirect=True): """Redirect lo login if not authenticated or abort with 401.""" if utils.current_user.is_authenticated or not login_redirect: admin_audit.log(SystemEvents.unauthorized_request) abort(401) return redirect(url_for("usm_login.get_login", next=request.url))