def after_request(resp): """ Adds HTTP Headers to the response Arguments ---------- resp : flask.Response object the Flask response object """ resp.headers['Cache-Control'] = 'no-cache' if app.config['DEV']: resp.headers['Access-Control-Allow-Origin'] = '*' resp.headers['Access-Control-Allow-Methods'] = 'GET, POST' resp.headers['Access-Control-Allow-Headers'] = 'Origin, X-Requested-With, Content-Type, Accept' source_ip = get_real_source_ip() structured_log( level='info', msg="HTTP Request", method=request.method, uri=request.path, status=resp.status_code, src_ip=source_ip, protocol=request.environ['SERVER_PROTOCOL'], user_agent=request.environ.get('HTTP_USER_AGENT', '-') ) return resp
def __init__(self): self.prefix = __app__.lower() self.host = os.environ.get("STATSD_HOST", "localhost") try: self.port = int(os.environ.get('STATSD_PORT', 8125)) except ValueError: self.port = 8125 # check that STATSD_HOST is a valid hostname or IPv4 address if self.host != "localhost": try: socket.gethostbyname(self.host) except: try: socket.inet_aton(self.host) except: structured_log(level='error', msg="Invalid value provided for STATSD_HOST. Defaulting to localhost") self.host = "localhost" self.client = StatsClient(host=self.host, port=self.port, prefix=self.prefix) structured_log( level='info', msg="Statsd client configured to export metrics to %s:%s" % (self.host, self.port) )
def exception_handler(err): """ Catches any exceptions raised by this microservice and responds with HTTP 500 and a custom error message. """ tb = traceback.format_exc() formatted_traceback = ' '.join(tb.splitlines()[1:]).strip() structured_log(level='error', msg="Encountered an exception", traceback=formatted_traceback) return jsonify(error="The application failed to handle the request"), 500
def verify_endpoint(): """ Verifies if a JWT is valid. """ if request.method == 'POST': if not app.config["JWT"]: return jsonify(message="JWT verification is not enabled"), 501 request_json = request.get_json(force=True, cache=False) token = request_json.get('jwt', None) if token is None: return jsonify(message="No JWT provided"), 400 try: claims = jwt.get_unverified_claims(token) except: return jsonify(message="Invalid JWT"), 400 master_key = app.config["JWT_MASTER_KEY"] algorithm = app.config["JWT_ALGORITHM"] issuer = app.config['APP_NAME'] try: subject = claims["sub"].encode('utf-8') salt = claims["x"].encode('utf-8') except KeyError: return jsonify(message="Invalid claims in JWT", valid=False), 401 # jwt.decode() requires secret_key to be a str, so it must be decoded secret_key = blake2b(b'', key=master_key, salt=salt, person=subject).decode('utf-8') # exception is raised if token has expired, signature verification fails, etc. try: jwt.decode(token=token, key=secret_key, algorithms=algorithm, issuer=issuer) except Exception as err: structured_log(level='info', msg="Failed to verify JWT", error=err) return jsonify(message="Failed to verify JWT", valid=False), 401 statsd.client.incr("jwt_verified") structured_log(level='info', msg="JWT successfully verified", user=f"'{subject}'") return jsonify(message="JWT successfully verified", valid=True), 200
def create_app(): """ Initializes the Flask application. """ structured_log(level='info', msg="Starting beesly...") try: settings = initialize_config() except ConfigError: structured_log(level='critical', msg="Failed to load configuration. Exiting...") sys.exit(4) # enable Swagger UI if running in DEV mode if settings["DEV"]: app.static_folder = os.path.dirname(os.path.realpath(__file__)) + "/swagger-ui" app.add_url_rule("/service/docs/<path:filename>", endpoint="/service/docs", view_func=app.send_static_file) app.config.update(settings) structured_log(level='info', msg="Successfully loaded configuration") rlimiter.init_app(app) return app
def renew_endpoint(): """ Renews a JWT that has not expired. """ if request.method == 'POST': if not app.config["JWT"]: return jsonify(message="JWT renewal is not enabled"), 501 request_json = request.get_json(force=True, cache=False) token = request_json.get('jwt', None) username = request_json.get('username', None) if token is None or username is None: return jsonify(message="No JWT or username provided"), 400 sanitized_username = str(escape(username)) if not validate_username(sanitized_username): structured_log(level='warning', msg="Invalid username provided", user=f"'{sanitized_username}'") return jsonify(message="Invalid username provided"), 400 try: claims = jwt.get_unverified_claims(token) except JWTError: return jsonify(message="Invalid JWT"), 400 try: subject = claims["sub"].encode('utf-8') salt = claims["x"].encode('utf-8') except KeyError: return jsonify(message="Invalid claims in JWT"), 401 if sanitized_username != claims["sub"]: return jsonify(message="Invalid subject in JWT claim"), 400 master_key = app.config["JWT_MASTER_KEY"] algorithm = app.config["JWT_ALGORITHM"] issuer = app.config['APP_NAME'] # jwt.decode() requires secret_key to be a str, so it must be decoded secret_key = blake2b(b'', key=master_key, salt=salt, person=subject).decode('utf-8') # exception is raised if token has expired, signature verification fails, etc. try: payload = jwt.decode(token=token, key=secret_key, algorithms=algorithm, issuer=issuer) except Exception as err: structured_log(level='info', msg="Failed to renew JWT", error=err) return jsonify(message="Failed to renew invalid JWT"), 401 issue_time = time.time() expiry_time = issue_time + app.config['JWT_VALIDITY_PERIOD'] salt = URLSafeBase64Encoder.encode(nacl.utils.random(12)) payload['iat'] = issue_time payload['exp'] = expiry_time payload['x'] = salt.decode('utf-8') # compute a new secret key for the regenerated JWT new_secret_key = blake2b(b'', key=master_key, salt=salt, person=subject).decode('utf-8') new_token = jwt.encode(claims=payload, key=new_secret_key, algorithm=algorithm) statsd.client.incr("jwt_renewed") structured_log(level='info', msg="JWT successfully renewed", user="******".format(sanitized_username)) return jsonify(message="JWT successfully renewed", jwt=new_token), 200
def auth_endpoint(): """ Authenticates users using PAM. If authentication is successful, returns a list of groups the user is a member of. Returns a short-lived JSON Web Token if JWT_MASTER_KEY is set. """ if request.method == 'POST': request_json = request.get_json(force=True, cache=False) username = request_json.get('username', None) password = request_json.get('password', None) if username is None or password is None: return jsonify(message="No username or password provided"), 400 sanitized_username = str(escape(username)) if not validate_username(sanitized_username): structured_log(level='warning', msg="Invalid username provided", user=f"'{sanitized_username}'") return jsonify(message="Invalid username provided"), 400 pam_service = app.config['PAM_SERVICE'] with statsd.client.timer("pam_auth"): if pam().authenticate(sanitized_username, password, service=pam_service): authenticated = True auth_message = "Authentication successful" statsd.client.incr("auth_success") else: authenticated = False auth_message = "Authentication failed" statsd.client.incr("auth_failed") structured_log(level='info', msg=auth_message, user=f"'{sanitized_username}'") if authenticated: groups = get_group_membership(sanitized_username) token = None if app.config["JWT"]: issuer = app.config['APP_NAME'] issue_time = time.time() expiry_time = issue_time + app.config['JWT_VALIDITY_PERIOD'] subject = sanitized_username.encode('utf-8') # generate a unique salt for each JWT, needs to be encoded to include as a claim salt = URLSafeBase64Encoder.encode(nacl.utils.random(12)) claims = { "iss": issuer, "iat": issue_time, "exp": expiry_time, "sub": sanitized_username, "groups": groups, "x": salt.decode('utf-8') } master_key = app.config["JWT_MASTER_KEY"] algorithm = app.config["JWT_ALGORITHM"] # generate a unique secret key for each JWT secret_key = blake2b(b'', key=master_key, salt=salt, person=subject).decode('utf-8') token = jwt.encode(claims=claims, key=secret_key, algorithm=algorithm) statsd.client.incr("jwt_generated") return jsonify(message=f"{auth_message}", auth=True, groups=groups, jwt=token), 200 else: return jsonify(message=f"{auth_message}", auth=False), 401
def initialize_config(): """ Initializes the application's configuration by reading settings from environment variables and validating them. Returns a dictionary containing application configuration if validation passes, otherwise ConfigError exception is raised. """ settings = {} # ensure that all dependencies used exist dependencies = ['id'] for binary in dependencies: if find_executable(binary) is None: structured_log(level='critical', msg="Failed to locate required dependency", dependency=binary) raise ConfigError() settings['APP_NAME'] = __app__ settings['APP_VERSION'] = __version__ settings['DEV'] = strtobool(os.environ.get("DEV", 'False')) settings["RATELIMIT_ENABLED"] = strtobool(os.environ.get("RATELIMIT_ENABLED", 'True')) settings["RATELIMIT_STRATEGY"] = os.environ.get("RATELIMIT_STRATEGY", 'fixed-window') settings["RATELIMIT_STORAGE_URL"] = os.environ.get("RATELIMIT_STORAGE_URL", 'memory://') if not settings["RATELIMIT_ENABLED"]: structured_log(level='info', msg="Rate limiting disabled") if settings["RATELIMIT_ENABLED"]: rate_limit_strategies = ['fixed-window', 'fixed-window-elastic-expiry', 'moving-window'] rate_limit_storage_schemes = ['memory', 'memcached', 'redis', 'rediss', 'redis+sentinel', 'redis_cluster'] storage_scheme = urlparse(settings["RATELIMIT_STORAGE_URL"]).scheme if settings["RATELIMIT_STRATEGY"] not in rate_limit_strategies: structured_log(level='error', msg="Invalid value provided for RATELIMIT_STRATEGY") raise ConfigError() if storage_scheme not in rate_limit_storage_schemes: structured_log(level='error', msg="Invalid value provided for RATELIMIT_STORAGE_URL") raise ConfigError() if settings["RATELIMIT_STRATEGY"] == 'moving-window' and storage_scheme == 'memcached': structured_log(level='error', msg="Invalid value provided for RATELIMIT_STORAGE_URL. moving-window can't be used with memcached") raise ConfigError() # python-pam module allows specfiying which PAM service by name to authenticate against settings['PAM_SERVICE'] = os.environ.get("PAM_SERVICE", 'login') pam_file = '/etc/pam.d/' + settings['PAM_SERVICE'] if not os.path.exists(pam_file): structured_log(level='error', msg="Invalid value provided for PAM_SERVICE. The pam configuration file '%s' does not exist" % pam_file) raise ConfigError() # configure JWT, by default it's disabled settings["JWT"] = False settings["JWT_MASTER_KEY"] = os.environ.get('JWT_MASTER_KEY', None) settings["JWT_ALGORITHM"] = os.environ.get('JWT_ALGORITHM', 'HS256') try: settings["JWT_VALIDITY_PERIOD"] = int(os.environ.get('JWT_VALIDITY_PERIOD', 900)) except ValueError: settings["JWT_VALIDITY_PERIOD"] = 900 if settings["JWT_MASTER_KEY"] is not None: if len(settings["JWT_MASTER_KEY"]) < 10: structured_log(level='error', msg="Invalid value provided for JWT_MASTER_KEY. Must be at least 10 characters long") raise ConfigError() if settings["JWT_ALGORITHM"] not in ['HS256', 'HS384', 'HS512']: structured_log(level='error', msg="Invalid value provided for JWT_ALGORITHM. Defaulting to HS256") settings["JWT_ALGORITHM"] = 'HS256' settings["JWT"] = True return settings