Skip to content

Commit

Permalink
Merge pull request SUNET#316 from Josephine-Rutten/feature.authentica…
Browse files Browse the repository at this point in the history
…tionwithoauth

Authentication setup
  • Loading branch information
indy-independence authored Jan 16, 2024
2 parents 8740bb6 + 308c0b5 commit 0e22aec
Show file tree
Hide file tree
Showing 24 changed files with 521 additions and 144 deletions.
8 changes: 4 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.3.0
rev: v4.5.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 22.10.0
rev: 23.11.0
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 5.11.2
rev: 5.12.0
hooks:
- id: isort
name: isort (python)
- repo: https://github.com/pycqa/flake8
rev: 5.0.4
rev: 6.1.0
hooks:
- id: flake8
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ python3 -m cnaas_nms.api.tests.test_api
python3 -m cnaas_nms.confpush.tests.test_get
```

## Authorization

Currently we can use two styles for the authorization. We can use the original style or use OIDC style. For OIDC we need to define some env variables or add a auth_config.yaml in the config. The needed variables are: OIDC_CONF_WELL_KNOWN_URL, OIDC_CLIENT_SECRET, OIDC_CLIENT_ID, FRONTEND_CALLBACK_URL and OIDC_ENABLED. To use the OIDC style the last variable needs to be set to true.

## License

Copyright (c) 2019 - 2020, SUNET (BSD 2-clause license)
Expand Down
2 changes: 1 addition & 1 deletion docker/api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ RUN mkdir -p /opt/cnaas/templates /opt/cnaas/settings /opt/cnaas/venv \
COPY --chown=root:www-data cnaas-setup.sh createca.sh exec-pre-app.sh pytest.sh coverage.sh /opt/cnaas/

# Copy cnaas configuration files
COPY --chown=www-data:www-data config/api.yml config/db_config.yml config/plugins.yml config/repository.yml /etc/cnaas-nms/
COPY --chown=www-data:www-data config/api.yml config/auth_config.yml config/db_config.yml config/plugins.yml config/repository.yml /etc/cnaas-nms/


USER www-data
Expand Down
5 changes: 5 additions & 0 deletions docker/api/config/auth_config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
oidc_conf_well_known_url: "well-known-openid-configuration-endpoint"
oidc_client_secret: "xxx"
oidc_client_id: "client-id"
frontend_callback_url: "http://localhost/callback"
oidc_enabled: False
11 changes: 11 additions & 0 deletions docs/configuration/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ Defines parameters for the API:
- commit_confirmed_wait: Time to wait between comitting configuration and checking
that the device is still reachable, specified in seconds. Defaults to 1.

/etc/cnaas-nms/auth_config.yml
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Define parameters for the authentication:

- oidc_conf_well_known_url: set the url for the oidc
- oidc_client_secret: set the secret of the oidc
- oidc_client_id: set the client_id of the oidc
- frontend_callback_url: set the frontend url the oidc client should link to after the login process
- oidc_enabled: set True to enabled the oidc login. Default: False

/etc/cnaas-nms/repository.yml
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@ SQLAlchemy-Utils==0.38.3
pydantic==1.10.2
Werkzeug==2.2.3
greenlet==3.0.1
Authlib==1.0.1
python-jose==3.1.0
102 changes: 77 additions & 25 deletions src/cnaas_nms/api/app.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import os
import re
import sys
from typing import Optional

from typing import Optional
from engineio.payload import Payload
from flask import Flask, jsonify, request
from flask_cors import CORS
from flask_jwt_extended import JWTManager, decode_token
from flask_jwt_extended import JWTManager
from flask_jwt_extended.exceptions import InvalidHeaderError, NoAuthorizationError
from flask_restx import Api
from flask_socketio import SocketIO, join_room
from jwt.exceptions import DecodeError, InvalidSignatureError, InvalidTokenError
from jwt import decode
from jwt.exceptions import DecodeError, InvalidSignatureError, InvalidTokenError, ExpiredSignatureError, InvalidKeyError
from authlib.integrations.flask_client import OAuth
from authlib.oauth2.rfc6749 import MissingAuthorizationError


from cnaas_nms.api.device import (
device_api,
Expand All @@ -24,6 +28,7 @@
device_update_interfaces_api,
devices_api,
)
from cnaas_nms.api.auth import api as auth_api
from cnaas_nms.api.firmware import api as firmware_api
from cnaas_nms.api.groups import api as groups_api
from cnaas_nms.api.interface import api as interfaces_api
Expand All @@ -35,11 +40,15 @@
from cnaas_nms.api.repository import api as repository_api
from cnaas_nms.api.settings import api as settings_api
from cnaas_nms.api.system import api as system_api

from cnaas_nms.app_settings import auth_settings
from cnaas_nms.app_settings import api_settings

from cnaas_nms.tools.log import get_logger
from cnaas_nms.tools.security import get_jwt_identity, jwt_required
from cnaas_nms.tools.security import get_oauth_userinfo
from cnaas_nms.version import __api_version__


logger = get_logger()


Expand All @@ -52,31 +61,53 @@
}
}

jwt_query_r = re.compile(r"jwt=[^ &]+")
jwt_query_r = re.compile(r"code=[^ &]+")


class CnaasApi(Api):
def handle_error(self, e):
if isinstance(e, DecodeError):
data = {"status": "error", "data": "Could not decode JWT token"}
data = {"status": "error", "message": "Could not decode JWT token"}
elif isinstance(e, InvalidKeyError):
data = {"status": "error", "message": "Invalid keys {}".format(e)}
elif isinstance(e, InvalidTokenError):
data = {"status": "error", "data": "Invalid authentication header: {}".format(e)}
data = {"status": "error", "message": "Invalid authentication header: {}".format(e)}
elif isinstance(e, InvalidSignatureError):
data = {"status": "error", "data": "Invalid token signature"}
data = {"status": "error", "message": "Invalid token signature"}
elif isinstance(e, IndexError):
# We might catch IndexErrors which are not cuased by JWT,
# We might catch IndexErrors which are not caused by JWT,
# but this is better than nothing.
data = {"status": "error", "data": "JWT token missing?"}
data = {"status": "error", "message": "JWT token missing?"}
elif isinstance(e, NoAuthorizationError):
data = {"status": "error", "data": "JWT token missing?"}
data = {"status": "error", "message": "JWT token missing?"}
elif isinstance(e, InvalidHeaderError):
data = {"status": "error", "data": "Invalid header, JWT token missing? {}".format(e)}
data = {"status": "error", "message": "Invalid header, JWT token missing? {}".format(e)}
elif isinstance(e, ExpiredSignatureError):
data = {"status": "error", "message": "The JWT token is expired"}
elif isinstance(e, MissingAuthorizationError):
data = {"status": "error", "message": "JWT token missing?"}
elif isinstance(e, ConnectionError):
data = {"status": "error", "message": "ConnectionError: {}".format(e)}
return jsonify(data), 500
else:
return super(CnaasApi, self).handle_error(e)
return jsonify(data), 401


app = Flask(__name__)

# To register the OAuth client
oauth = OAuth(app)
client = oauth.register(
"connext",
server_metadata_url=auth_settings.OIDC_CONF_WELL_KNOWN_URL,
client_id=auth_settings.OIDC_CLIENT_ID,
client_secret=auth_settings.OIDC_CLIENT_SECRET,
client_kwargs={"scope": auth_settings.OIDC_CLIENT_SCOPE},
response_type="code",
response_mode="query",
)

app.config["RESTX_JSON"] = {"cls": CNaaSJSONEncoder}

# TODO: make origins configurable
Expand All @@ -88,14 +119,16 @@ def handle_error(self, e):
Payload.max_decode_packets = 500
socketio = SocketIO(app, cors_allowed_origins="*")


if api_settings.JWT_ENABLED or auth_settings.OIDC_ENABLED:
app.config["SECRET_KEY"] = os.urandom(128)
if api_settings.JWT_ENABLED:
try:
jwt_pubkey = open(api_settings.JWT_CERT).read()
except Exception as e:
print("Could not load public JWT cert from api.yml config: {}".format(e))
sys.exit(1)

app.config["SECRET_KEY"] = os.urandom(128)
app.config["JWT_PUBLIC_KEY"] = jwt_pubkey
app.config["JWT_IDENTITY_CLAIM"] = "sub"
app.config["JWT_ALGORITHM"] = "ES256"
Expand All @@ -108,6 +141,7 @@ def handle_error(self, e):
app, prefix="/api/{}".format(__api_version__), authorizations=authorizations, security="apikey", doc="/api/doc/"
)

api.add_namespace(auth_api)
api.add_namespace(device_api)
api.add_namespace(devices_api)
api.add_namespace(device_init_api)
Expand All @@ -133,12 +167,28 @@ def handle_error(self, e):
api.add_namespace(plugins_api)
api.add_namespace(system_api)


# SocketIO on connect
@socketio.on("connect")
@jwt_required
def socketio_on_connect():
user = get_jwt_identity()
# get te token string
token_string = request.args.get('jwt')
if not token_string:
return False
#if oidc, get userinfo
if auth_settings.OIDC_ENABLED:
try:
user = get_oauth_userinfo(token_string)['email']
except InvalidTokenError as e:
logger.debug('InvalidTokenError: ' + format(e))
return False
# else decode the token and get the sub there
else:
try:
user = decode(token_string, app.config["JWT_PUBLIC_KEY"], algorithms=[app.config["JWT_ALGORITHM"]])['sub']
except DecodeError as e:
logger.debug('DecodeError: ' + format(e))
return False

if user:
logger.info("User: {} connected via socketio".format(user))
return True
Expand All @@ -165,18 +215,20 @@ def socketio_on_events(data):
# Log all requests, include username etc
@app.after_request
def log_request(response):
try:
token = request.headers.get("Authorization").split(" ")[-1]
user = decode_token(token).get("sub")
except Exception:
user = "unknown"
try:
url = re.sub(jwt_query_r, "", request.url)
logger.info(
"User: {}, Method: {}, Status: {}, URL: {}, JSON: {}".format(
user, request.method, response.status_code, url, request.json
if request.headers.get('content-type') == 'application/json':
logger.info(
"Method: {}, Status: {}, URL: {}, JSON: {}".format(
request.method, response.status_code, url, request.json
)
)
else:
logger.info(
"Method: {}, Status: {}, URL: {}".format(
request.method, response.status_code, url
)
)
)
except Exception:
pass
return response
94 changes: 94 additions & 0 deletions src/cnaas_nms/api/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
from authlib.integrations.base_client.errors import MismatchingStateError, OAuthError
from flask import current_app, redirect, url_for
from flask_restx import Namespace, Resource
from requests.models import PreparedRequest

from cnaas_nms.api.generic import empty_result
from cnaas_nms.app_settings import auth_settings
from cnaas_nms.tools.log import get_logger
from cnaas_nms.tools.security import get_identity, login_required
from cnaas_nms.version import __api_version__

logger = get_logger()
api = Namespace("auth", description="API for handling auth", prefix="/api/{}".format(__api_version__))


class LoginApi(Resource):
def get(self):
"""Function to initiate a login of the user.
The user will be sent to the page to login.
Our client info will also be checked.
Note:
We also discussed adding state to this function.
That way you could be sent to the same page once you logged in.
We would put the relevant information in a dictionary,
base64 encode it and sent it around as a parameter.
For now the application is small and it didn't seem needed.
Returns:
A HTTP redirect response to OIDC_CONF_WELL_KNOWN_URL we have defined.
We give the auth call as a parameter to redirect after login is successfull.
"""
if not auth_settings.OIDC_ENABLED:
return empty_result(status="error", data="Can't login when OIDC disabled"), 500
oauth_client = current_app.extensions["authlib.integrations.flask_client"]
redirect_uri = url_for("auth_auth_api", _external=True)

return oauth_client.connext.authorize_redirect(redirect_uri)


class AuthApi(Resource):
def get(self):
"""Function to authenticate the user.
This API call is called by the OAUTH login after the user has logged in.
We get the users token and redirect them to right page in the frontend.
Returns:
A HTTP redirect response to the url in the frontend that handles the repsonse after login.
The access token is a parameter in the url
"""

oauth_client = current_app.extensions["authlib.integrations.flask_client"]

try:
token = oauth_client.connext.authorize_access_token()
except MismatchingStateError as e:
logger.error("Exception during authorization of the access token: {}".format(str(e)))
return (
empty_result(
status="error",
data="Exception during authorization of the access token. Please try to login again.",
),
502,
)
except OAuthError as e:
logger.error("Missing information needed for authorization: {}".format(str(e)))
return (
empty_result(
status="error",
data="The server is missing some information that is needed for authorization.",
),
500,
)

url = auth_settings.FRONTEND_CALLBACK_URL
parameters = {"token": token["access_token"]}

req = PreparedRequest()
req.prepare_url(url, parameters)
return redirect(req.url, code=302)


class IdentityApi(Resource):
@login_required
def get(self):
identity = get_identity()
return identity


api.add_resource(LoginApi, "/login")
api.add_resource(AuthApi, "/auth")
api.add_resource(IdentityApi, "/identity")
Loading

0 comments on commit 0e22aec

Please sign in to comment.