Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/check-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
fail-fast: false
matrix:
platform: ["ubuntu-latest", "macos-13"]
python-version: ["3.8", "3.9", "3.10", "3.11"]
python-version: ["3.9", "3.10", "3.11", "3.12"]

name: Python ${{ matrix.python-version }} on ${{ matrix.platform }}
runs-on: ${{ matrix.platform }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
fail-fast: false
matrix:
platform: ["ubuntu-latest","macos-13"]
python-version: ["3.8", "3.9", "3.10", "3.11"]
python-version: ["3.9", "3.10", "3.11", "3.12"]

name: Python ${{ matrix.python-version }} on ${{ matrix.platform }}
runs-on: ${{ matrix.platform }}
Expand Down
2 changes: 1 addition & 1 deletion aviso-server/auth/aviso_auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

# version number for the application.

__version__ = "0.4.0"
__version__ = "0.5.0"

# setting application logger
logger = logging.getLogger("aviso-auth")
Expand Down
352 changes: 247 additions & 105 deletions aviso-server/auth/aviso_auth/authentication.py

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions aviso-server/auth/aviso_auth/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,14 @@ def __init__(
def _create_default_config() -> Dict[str, any]:
# authentication_server
authentication_server = {}
authentication_server["url"] = "https://api.ecmwf.int/v1/who-am-i"
authentication_server["url"] = "http://0.0.0.0:8080"
authentication_server["req_timeout"] = 60 # seconds
authentication_server["cache_timeout"] = 86400 # 1 day in seconds
authentication_server["monitor"] = False

# authorisation_server
authorisation_server = {}
authorisation_server["url"] = "https://127.0..0.1:8080"
authorisation_server["url"] = "http://127.0.0.1:8080"
authorisation_server["req_timeout"] = 60 # seconds
authorisation_server["cache_timeout"] = 86400 # 1 day in seconds
authorisation_server["open_keys"] = ["/ec/mars", "/ec/config/aviso"]
Expand Down
134 changes: 81 additions & 53 deletions aviso-server/auth/aviso_auth/frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
# nor does it submit to any jurisdiction.

import json
import logging

import aviso_auth.custom_exceptions as custom
import gunicorn.app.base
Expand All @@ -22,39 +21,49 @@
from aviso_monitoring.reporter.aviso_auth_reporter import AvisoAuthMetricType
from flask import Flask, Response, render_template, request
from flask_caching import Cache
from gunicorn import glogging
from six import iteritems


class Frontend:
def __init__(self, config: Config):
self.config = config
self.handler = self.create_handler()
self.handler.cache = Cache(self.handler, config=config.cache)
# we need to initialise our components and timer here if this app runs in Flask,
# if instead it runs in Gunicorn the hook post_worker_init will take over, and these components will not be used

# For direct runs (e.g. Flask "server_type"):
# We'll initialize the app-level components now.
self.init_components()

def init_components(self):
"""
This method initialise a set of components and timers that are valid globally at application level or per worker
Initializes the Authenticator, Authoriser, BackendAdapter,
and sets up time-collectors or counters as needed.
"""
# Create the authenticator (with caching if provided)
self.authenticator = Authenticator(self.config, self.handler.cache)
self.authoriser = Authoriser(self.config, self.handler.cache)
self.backend = BackendAdapter(self.config)
# this is a time collector for the whole request

# A time collector for measuring entire request durations (via timed_process_request()).
self.timer = TimeCollector(self.config.monitoring, tlm_type=AvisoAuthMetricType.auth_resp_time.name)

# A UniqueCountCollector for counting user accesses. This is used in process_request().
self.user_counter = UniqueCountCollector(
self.config.monitoring, tlm_type=AvisoAuthMetricType.auth_users_counter.name
)

logger.debug("All components initialized: Authenticator, Authoriser, BackendAdapter, timers, counters")

def create_handler(self) -> Flask:
handler = Flask(__name__)
handler.title = "aviso-auth"
# We need to bind the logger of aviso to the one of app

# Bind aviso_auth logger to the Flask app logger.
logger.handlers = handler.logger.handlers

def json_response(m, code, header=None):
"""
Utility for building JSON response.
"""
h = {"Content-Type": "application/json"}
if header:
h.update(header)
Expand All @@ -67,8 +76,30 @@ def invalid_input(e):

@handler.errorhandler(custom.TokenNotValidException)
def token_not_valid(e):
"""
Return a 401 and attach a dynamic WWW-Authenticate header if present in the exception message.
If not, fall back to the default header from the Authenticator.
"""
logger.debug(f"Authentication failed: {e}")
return json_response(e, 401, self.authenticator.UNAUTHORISED_RESPONSE_HEADER)

# Try to extract a dynamic www-authenticate header from the exception message.
# We assume the exception message is formatted like:
# "Invalid credentials or unauthorized token; www-authenticate: <header value>"
msg = str(e)
header = {}
if "www-authenticate:" in msg.lower():
try:
# Split on "www-authenticate:" and take the remainder.
dynamic_value = msg.split("www-authenticate:")[1].strip()
header["WWW-Authenticate"] = dynamic_value
logger.debug("Using dynamic WWW-Authenticate header: %s", dynamic_value)
except Exception as parse_err:
logger.error("Failed to parse dynamic WWW-Authenticate header: %s", parse_err)
header = self.authenticator.UNAUTHORISED_RESPONSE_HEADER
else:
header = self.authenticator.UNAUTHORISED_RESPONSE_HEADER

return json_response(e, 401, header)

@handler.errorhandler(custom.ForbiddenDestinationException)
def forbidden_destination(e):
Expand Down Expand Up @@ -106,52 +137,66 @@ def default_error_handler(e):

@handler.route("/", methods=["GET"])
def index():
"""
Simple index route that renders an index.html template
(if shipping a front-end).
Otherwise, can return a basic message.
"""
return render_template("index.html")

@handler.route(self.config.backend["route"], methods=["POST"])
def root():
logger.info(f"New request received from {request.headers.get('X-Forwarded-For')}, content: {request.data}")

"""
The main route for your proxying or backend forwarding logic.
"""
logger.info(
f"New request received from {request.headers.get('X-Forwarded-For')}, " f"content: {request.data}"
)
resp_content = timed_process_request()

# forward back the response
return Response(resp_content)

def process_request():
# authenticate request and count the users
"""
The main request processing flow:
1. Authenticate
2. Authorise
3. Forward to backend
"""
# (1) Authenticate request and increment user counter
username = self.user_counter(self.authenticator.authenticate, args=request)
logger.debug("Request successfully authenticated")

# authorise request
# (2) Authorise request
valid = self.authoriser.is_authorised(username, request)
if not valid:
raise custom.ForbiddenDestinationException("User not allowed to access to the resource")
raise custom.ForbiddenDestinationException("User not allowed to access the resource")
logger.debug("Request successfully authorised")

# forward request to backend
# (3) Forward request to backend
resp_content = self.backend.forward(request)
logger.info("Request completed")

return resp_content

def timed_process_request():
"""
This method allows time the process_request function
Wraps process_request in a time collector (self.timer).
"""
return self.timer(process_request)

return handler

def run_server(self):
"""
Launches the server using either Flask's built-in server or Gunicorn.
"""
logger.info(
f"Running aviso-auth - version {__version__} on server {self.config.frontend['server_type']}, \
aviso_monitoring module v.{monitoring_version}"
f"Running aviso-auth - version {__version__} on server {self.config.frontend['server_type']}, "
f"aviso_monitoring module v.{monitoring_version}"
)
logger.info(f"Configuration loaded: {self.config}")

if self.config.frontend["server_type"] == "flask":
# flask internal server for non-production environments
# should only be used for testing and debugging
# Not recommended for production, but good for dev/test
self.handler.run(
debug=self.config.debug,
host=self.config.frontend["host"],
Expand All @@ -171,58 +216,41 @@ def run_server(self):

def post_worker_init(self, worker):
"""
This method is called just after a worker has initialized the application.
It is a Gunicorn server hook. Gunicorn spawns this app over multiple workers as processes.
This method ensures that there is only one set of components and timer running per worker. Without this hook
the components and timers are created at application level but not at worker level and then at every request a
timers will be created detached from the main transmitter threads.
This would result in no telemetry collected.
Called just after a worker initializes the application in Gunicorn.
Re-initializes any components that need a separate instance per worker process.
"""
logger.debug("Initialising components per worker")
self.init_components()


def main():
# initialising the user configuration configuration
config = Config()

# create the frontend class and run it
frontend = Frontend(config)
frontend.run_server()


class GunicornServer(gunicorn.app.base.BaseApplication):
"""
Gunicorn server wrapper.
"""

def __init__(self, app, options=None):
self.options = options or {}
self.application = app
super(GunicornServer, self).__init__()
super().__init__()

def load_config(self):
from six import iteritems

config = dict(
[(key, value) for key, value in iteritems(self.options) if key in self.cfg.settings and value is not None]
)
for key, value in iteritems(config):
self.cfg.set(key.lower(), value)

# this approach does not support custom filters, therefore it's better to disable it
# self.cfg.set('logger_class', GunicornServer.CustomLogger)

def load(self):
return self.application

class CustomLogger(glogging.Logger):
"""Custom logger for Gunicorn log messages."""

def setup(self, cfg):
"""Configure Gunicorn application logging configuration."""
super().setup(cfg)

formatter = logging.getLogger().handlers[0].formatter

# Override Gunicorn's `error_log` configuration.
self._set_handler(self.error_log, cfg.errorlog, formatter)
def main():
config = Config()
frontend = Frontend(config)
frontend.run_server()


# when running directly from this file
if __name__ == "__main__":
main()
3 changes: 2 additions & 1 deletion aviso-server/auth/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
PyYAML>=5.1.2
python-json-logger>=0.1.11
requests>=2.23.0
gunicorn>=20.0.4
gunicorn>=23.0.0
flask>=1.1.2
Flask-Caching>=1.8.0
six>=1.15.0
rfc5424-logging-handler>=1.4.3
PyJWT>=2.10.1
2 changes: 2 additions & 0 deletions pyaviso/authentication/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class AuthType(Enum):
"""

ECMWF = ("ecmwf_auth", "EcmwfAuth")
OPENID = ("openid_auth", "OpenidAuth")
PLAIN = ("plain_auth", "PlainAuth")
ETCD = ("etcd_auth", "EtcdAuth")
NONE = ("none_auth", "NoneAuth")

Expand Down
3 changes: 1 addition & 2 deletions pyaviso/authentication/ecmwf_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,4 @@ def __init__(self, config: UserConfig):
self._username = config.username

def header(self):
header = {"Authorization": f"EmailKey {self.username}:{self.password}"}
return header
return {"Authorization": f"Bearer {self._password}", "X-Auth-Type": "ecmwf"}
22 changes: 22 additions & 0 deletions pyaviso/authentication/openid_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# (C) Copyright 1996- ECMWF.
#
# This software is licensed under the terms of the Apache Licence Version 2.0
# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
# In applying this licence, ECMWF does not waive the privileges and immunities
# granted to it by virtue of its status as an intergovernmental organisation
# nor does it submit to any jurisdiction.


class OpenidAuth:
"""
OpenidAuth implements an OpenID authentication flow.

It returns a Bearer header (using the shared secret from config.password) and adds
an extra header "X-Auth-Type" with the value "openid".
"""

def __init__(self, config):
self.config = config

def header(self):
return {"Authorization": f"Bearer {self.config.password}", "X-Auth-Type": "openid"}
23 changes: 23 additions & 0 deletions pyaviso/authentication/plain_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# (C) Copyright 1996- ECMWF.
#
# This software is licensed under the terms of the Apache Licence Version 2.0
# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
# In applying this licence, ECMWF does not waive the privileges and immunities
# granted to it by virtue of its status as an intergovernmental organisation
# nor does it submit to any jurisdiction.

import base64


class PlainAuth:
"""
PlainAuth implements Basic authentication.
"""

def __init__(self, config):
self.config = config

def header(self):
credentials = f"{self.config.username}:{self.config.password}"
encoded = base64.b64encode(credentials.encode("utf-8")).decode("utf-8")
return {"Authorization": f"Basic {encoded}", "X-Auth-Type": "plain"}
2 changes: 1 addition & 1 deletion pyaviso/cli_aviso.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ def notify(parameters: str, configuration: conf.UserConfig):
cli.add_command(notify)

if __name__ == "__main__":
listen()
cli()


def _parse_inline_params(params: str) -> Dict[str, any]:
Expand Down
2 changes: 1 addition & 1 deletion pyaviso/engine/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ def push_with_status(
"""
# create the status payload
status = {
"etcd_user": self.auth.username,
"etcd_user": getattr(self.auth, "username", None),
"message": message,
"unix_user": getpass.getuser(),
"aviso_version": __version__,
Expand Down
2 changes: 1 addition & 1 deletion pyaviso/version.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# version number for the application
__version__ = "1.0.0"
__version__ = "1.1.0"