Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Authentication setup #316

Merged
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
4f58bc9
auth setup
Sep 19, 2023
223d765
Add OAUTH identity and required
Sep 26, 2023
3c5afa8
Extra error exception added for when the token is not valid yet.
Sep 28, 2023
6cdae7b
Merge branch 'develop' into feature.authenticationwithoauth
Josephine-Rutten Sep 28, 2023
50dd5d2
Changes based on Peter's comments and a TODO added to the /auth/test …
Sep 28, 2023
c4efe49
Merge branch 'feature.authenticationwithoauth' of https://github.com/…
Sep 28, 2023
06bfb98
Made get keys into it's own functions
Oct 16, 2023
fb26083
Removed some code smell, still 2 TODO to do
Oct 16, 2023
19a5544
Make JWT or OIDC option and some small improvements
Oct 20, 2023
34c5005
Made sure all the paths are of type Path
Oct 23, 2023
2397f0f
Improved comments
Oct 23, 2023
c379ca2
Merge branch 'SUNET:develop' into feature.authenticationwithoauth
Josephine-Rutten Nov 14, 2023
3624a39
added neccersary packages
Josephine-Rutten Nov 15, 2023
cf2a9c4
Update requirements.txt
Josephine-Rutten Nov 15, 2023
e25516f
Pre-commit update & formatting
Josephine-Rutten Nov 16, 2023
283bff5
make the key process simpler
Josephine-Rutten Dec 18, 2023
0c7133a
Comment at method
Josephine-Rutten Dec 19, 2023
4d9d17d
Improved security to work with different token types
Josephine-Rutten Jan 3, 2024
d5df744
Make eroor handling simpler
Josephine-Rutten Jan 4, 2024
16b4056
Chasnge Resource operator
Josephine-Rutten Jan 4, 2024
01692a9
Add an auth config option
Josephine-Rutten Jan 5, 2024
b5f681d
Update app_settings.py
Josephine-Rutten Jan 5, 2024
c2a38cf
Update app_settings.py
Josephine-Rutten Jan 5, 2024
1c94c0c
more error catching
Josephine-Rutten Jan 8, 2024
e287f04
Add exception handling
Josephine-Rutten Jan 8, 2024
ffc417b
Update security.py
Josephine-Rutten Jan 8, 2024
1435b15
Added extra error handling
Josephine-Rutten Jan 8, 2024
09ab48c
Add some authorization docs
Josephine-Rutten Jan 8, 2024
f2100f3
Add catching for no email error
Josephine-Rutten Jan 8, 2024
0a2edb1
Added option to have token in query string
Josephine-Rutten Jan 9, 2024
51c57cb
simplefied the connect socket call and removed unneccersary code
Josephine-Rutten Jan 10, 2024
6262f87
verify the token in the connect socket io
Josephine-Rutten Jan 11, 2024
6460ca5
Added scope as a env that can be set from env
Josephine-Rutten Jan 11, 2024
95bb738
better user feedback + error messages instead of data
Josephine-Rutten Jan 15, 2024
6924cfe
Added connection error catching for the wellknown url
Josephine-Rutten Jan 15, 2024
5afbf11
Add documentation auth_config
Josephine-Rutten Jan 15, 2024
4b703a2
Add excample auth_conf file
Josephine-Rutten Jan 15, 2024
308c0b5
Update auth_config.yml
Josephine-Rutten Jan 15, 2024
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
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: 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
99 changes: 74 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,50 @@
}
}

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?"}
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 +116,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 +138,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 +164,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 +212,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