Skip to content

Commit 0e22aec

Browse files
Merge pull request #316 from Josephine-Rutten/feature.authenticationwithoauth
Authentication setup
2 parents 8740bb6 + 308c0b5 commit 0e22aec

24 files changed

+521
-144
lines changed

.pre-commit-config.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
repos:
22
- repo: https://github.com/pre-commit/pre-commit-hooks
3-
rev: v2.3.0
3+
rev: v4.5.0
44
hooks:
55
- id: check-yaml
66
- id: end-of-file-fixer
77
- id: trailing-whitespace
88
- repo: https://github.com/psf/black
9-
rev: 22.10.0
9+
rev: 23.11.0
1010
hooks:
1111
- id: black
1212
- repo: https://github.com/pycqa/isort
13-
rev: 5.11.2
13+
rev: 5.12.0
1414
hooks:
1515
- id: isort
1616
name: isort (python)
1717
- repo: https://github.com/pycqa/flake8
18-
rev: 5.0.4
18+
rev: 6.1.0
1919
hooks:
2020
- id: flake8

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ python3 -m cnaas_nms.api.tests.test_api
4545
python3 -m cnaas_nms.confpush.tests.test_get
4646
```
4747

48+
## Authorization
49+
50+
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.
51+
4852
## License
4953

5054
Copyright (c) 2019 - 2020, SUNET (BSD 2-clause license)

docker/api/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ RUN mkdir -p /opt/cnaas/templates /opt/cnaas/settings /opt/cnaas/venv \
5757
COPY --chown=root:www-data cnaas-setup.sh createca.sh exec-pre-app.sh pytest.sh coverage.sh /opt/cnaas/
5858

5959
# Copy cnaas configuration files
60-
COPY --chown=www-data:www-data config/api.yml config/db_config.yml config/plugins.yml config/repository.yml /etc/cnaas-nms/
60+
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/
6161

6262

6363
USER www-data

docker/api/config/auth_config.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
oidc_conf_well_known_url: "well-known-openid-configuration-endpoint"
2+
oidc_client_secret: "xxx"
3+
oidc_client_id: "client-id"
4+
frontend_callback_url: "http://localhost/callback"
5+
oidc_enabled: False

docs/configuration/index.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,17 @@ Defines parameters for the API:
4848
- commit_confirmed_wait: Time to wait between comitting configuration and checking
4949
that the device is still reachable, specified in seconds. Defaults to 1.
5050

51+
/etc/cnaas-nms/auth_config.yml
52+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
53+
54+
Define parameters for the authentication:
55+
56+
- oidc_conf_well_known_url: set the url for the oidc
57+
- oidc_client_secret: set the secret of the oidc
58+
- oidc_client_id: set the client_id of the oidc
59+
- frontend_callback_url: set the frontend url the oidc client should link to after the login process
60+
- oidc_enabled: set True to enabled the oidc login. Default: False
61+
5162
/etc/cnaas-nms/repository.yml
5263
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
5364

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,5 @@ SQLAlchemy-Utils==0.38.3
3333
pydantic==1.10.2
3434
Werkzeug==2.2.3
3535
greenlet==3.0.1
36+
Authlib==1.0.1
37+
python-jose==3.1.0

src/cnaas_nms/api/app.py

Lines changed: 77 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import os
22
import re
33
import sys
4-
from typing import Optional
54

5+
from typing import Optional
66
from engineio.payload import Payload
77
from flask import Flask, jsonify, request
88
from flask_cors import CORS
9-
from flask_jwt_extended import JWTManager, decode_token
9+
from flask_jwt_extended import JWTManager
1010
from flask_jwt_extended.exceptions import InvalidHeaderError, NoAuthorizationError
1111
from flask_restx import Api
1212
from flask_socketio import SocketIO, join_room
13-
from jwt.exceptions import DecodeError, InvalidSignatureError, InvalidTokenError
13+
from jwt import decode
14+
from jwt.exceptions import DecodeError, InvalidSignatureError, InvalidTokenError, ExpiredSignatureError, InvalidKeyError
15+
from authlib.integrations.flask_client import OAuth
16+
from authlib.oauth2.rfc6749 import MissingAuthorizationError
17+
1418

1519
from cnaas_nms.api.device import (
1620
device_api,
@@ -24,6 +28,7 @@
2428
device_update_interfaces_api,
2529
devices_api,
2630
)
31+
from cnaas_nms.api.auth import api as auth_api
2732
from cnaas_nms.api.firmware import api as firmware_api
2833
from cnaas_nms.api.groups import api as groups_api
2934
from cnaas_nms.api.interface import api as interfaces_api
@@ -35,11 +40,15 @@
3540
from cnaas_nms.api.repository import api as repository_api
3641
from cnaas_nms.api.settings import api as settings_api
3742
from cnaas_nms.api.system import api as system_api
43+
44+
from cnaas_nms.app_settings import auth_settings
3845
from cnaas_nms.app_settings import api_settings
46+
3947
from cnaas_nms.tools.log import get_logger
40-
from cnaas_nms.tools.security import get_jwt_identity, jwt_required
48+
from cnaas_nms.tools.security import get_oauth_userinfo
4149
from cnaas_nms.version import __api_version__
4250

51+
4352
logger = get_logger()
4453

4554

@@ -52,31 +61,53 @@
5261
}
5362
}
5463

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

5766

5867
class CnaasApi(Api):
5968
def handle_error(self, e):
6069
if isinstance(e, DecodeError):
61-
data = {"status": "error", "data": "Could not decode JWT token"}
70+
data = {"status": "error", "message": "Could not decode JWT token"}
71+
elif isinstance(e, InvalidKeyError):
72+
data = {"status": "error", "message": "Invalid keys {}".format(e)}
6273
elif isinstance(e, InvalidTokenError):
63-
data = {"status": "error", "data": "Invalid authentication header: {}".format(e)}
74+
data = {"status": "error", "message": "Invalid authentication header: {}".format(e)}
6475
elif isinstance(e, InvalidSignatureError):
65-
data = {"status": "error", "data": "Invalid token signature"}
76+
data = {"status": "error", "message": "Invalid token signature"}
6677
elif isinstance(e, IndexError):
67-
# We might catch IndexErrors which are not cuased by JWT,
78+
# We might catch IndexErrors which are not caused by JWT,
6879
# but this is better than nothing.
69-
data = {"status": "error", "data": "JWT token missing?"}
80+
data = {"status": "error", "message": "JWT token missing?"}
7081
elif isinstance(e, NoAuthorizationError):
71-
data = {"status": "error", "data": "JWT token missing?"}
82+
data = {"status": "error", "message": "JWT token missing?"}
7283
elif isinstance(e, InvalidHeaderError):
73-
data = {"status": "error", "data": "Invalid header, JWT token missing? {}".format(e)}
84+
data = {"status": "error", "message": "Invalid header, JWT token missing? {}".format(e)}
85+
elif isinstance(e, ExpiredSignatureError):
86+
data = {"status": "error", "message": "The JWT token is expired"}
87+
elif isinstance(e, MissingAuthorizationError):
88+
data = {"status": "error", "message": "JWT token missing?"}
89+
elif isinstance(e, ConnectionError):
90+
data = {"status": "error", "message": "ConnectionError: {}".format(e)}
91+
return jsonify(data), 500
7492
else:
7593
return super(CnaasApi, self).handle_error(e)
7694
return jsonify(data), 401
7795

7896

7997
app = Flask(__name__)
98+
99+
# To register the OAuth client
100+
oauth = OAuth(app)
101+
client = oauth.register(
102+
"connext",
103+
server_metadata_url=auth_settings.OIDC_CONF_WELL_KNOWN_URL,
104+
client_id=auth_settings.OIDC_CLIENT_ID,
105+
client_secret=auth_settings.OIDC_CLIENT_SECRET,
106+
client_kwargs={"scope": auth_settings.OIDC_CLIENT_SCOPE},
107+
response_type="code",
108+
response_mode="query",
109+
)
110+
80111
app.config["RESTX_JSON"] = {"cls": CNaaSJSONEncoder}
81112

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

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

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

144+
api.add_namespace(auth_api)
111145
api.add_namespace(device_api)
112146
api.add_namespace(devices_api)
113147
api.add_namespace(device_init_api)
@@ -133,12 +167,28 @@ def handle_error(self, e):
133167
api.add_namespace(plugins_api)
134168
api.add_namespace(system_api)
135169

136-
137170
# SocketIO on connect
138171
@socketio.on("connect")
139-
@jwt_required
140172
def socketio_on_connect():
141-
user = get_jwt_identity()
173+
# get te token string
174+
token_string = request.args.get('jwt')
175+
if not token_string:
176+
return False
177+
#if oidc, get userinfo
178+
if auth_settings.OIDC_ENABLED:
179+
try:
180+
user = get_oauth_userinfo(token_string)['email']
181+
except InvalidTokenError as e:
182+
logger.debug('InvalidTokenError: ' + format(e))
183+
return False
184+
# else decode the token and get the sub there
185+
else:
186+
try:
187+
user = decode(token_string, app.config["JWT_PUBLIC_KEY"], algorithms=[app.config["JWT_ALGORITHM"]])['sub']
188+
except DecodeError as e:
189+
logger.debug('DecodeError: ' + format(e))
190+
return False
191+
142192
if user:
143193
logger.info("User: {} connected via socketio".format(user))
144194
return True
@@ -165,18 +215,20 @@ def socketio_on_events(data):
165215
# Log all requests, include username etc
166216
@app.after_request
167217
def log_request(response):
168-
try:
169-
token = request.headers.get("Authorization").split(" ")[-1]
170-
user = decode_token(token).get("sub")
171-
except Exception:
172-
user = "unknown"
173218
try:
174219
url = re.sub(jwt_query_r, "", request.url)
175-
logger.info(
176-
"User: {}, Method: {}, Status: {}, URL: {}, JSON: {}".format(
177-
user, request.method, response.status_code, url, request.json
220+
if request.headers.get('content-type') == 'application/json':
221+
logger.info(
222+
"Method: {}, Status: {}, URL: {}, JSON: {}".format(
223+
request.method, response.status_code, url, request.json
224+
)
225+
)
226+
else:
227+
logger.info(
228+
"Method: {}, Status: {}, URL: {}".format(
229+
request.method, response.status_code, url
230+
)
178231
)
179-
)
180232
except Exception:
181233
pass
182234
return response

src/cnaas_nms/api/auth.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from authlib.integrations.base_client.errors import MismatchingStateError, OAuthError
2+
from flask import current_app, redirect, url_for
3+
from flask_restx import Namespace, Resource
4+
from requests.models import PreparedRequest
5+
6+
from cnaas_nms.api.generic import empty_result
7+
from cnaas_nms.app_settings import auth_settings
8+
from cnaas_nms.tools.log import get_logger
9+
from cnaas_nms.tools.security import get_identity, login_required
10+
from cnaas_nms.version import __api_version__
11+
12+
logger = get_logger()
13+
api = Namespace("auth", description="API for handling auth", prefix="/api/{}".format(__api_version__))
14+
15+
16+
class LoginApi(Resource):
17+
def get(self):
18+
"""Function to initiate a login of the user.
19+
The user will be sent to the page to login.
20+
Our client info will also be checked.
21+
22+
Note:
23+
We also discussed adding state to this function.
24+
That way you could be sent to the same page once you logged in.
25+
We would put the relevant information in a dictionary,
26+
base64 encode it and sent it around as a parameter.
27+
For now the application is small and it didn't seem needed.
28+
29+
Returns:
30+
A HTTP redirect response to OIDC_CONF_WELL_KNOWN_URL we have defined.
31+
We give the auth call as a parameter to redirect after login is successfull.
32+
33+
"""
34+
if not auth_settings.OIDC_ENABLED:
35+
return empty_result(status="error", data="Can't login when OIDC disabled"), 500
36+
oauth_client = current_app.extensions["authlib.integrations.flask_client"]
37+
redirect_uri = url_for("auth_auth_api", _external=True)
38+
39+
return oauth_client.connext.authorize_redirect(redirect_uri)
40+
41+
42+
class AuthApi(Resource):
43+
def get(self):
44+
"""Function to authenticate the user.
45+
This API call is called by the OAUTH login after the user has logged in.
46+
We get the users token and redirect them to right page in the frontend.
47+
48+
Returns:
49+
A HTTP redirect response to the url in the frontend that handles the repsonse after login.
50+
The access token is a parameter in the url
51+
52+
"""
53+
54+
oauth_client = current_app.extensions["authlib.integrations.flask_client"]
55+
56+
try:
57+
token = oauth_client.connext.authorize_access_token()
58+
except MismatchingStateError as e:
59+
logger.error("Exception during authorization of the access token: {}".format(str(e)))
60+
return (
61+
empty_result(
62+
status="error",
63+
data="Exception during authorization of the access token. Please try to login again.",
64+
),
65+
502,
66+
)
67+
except OAuthError as e:
68+
logger.error("Missing information needed for authorization: {}".format(str(e)))
69+
return (
70+
empty_result(
71+
status="error",
72+
data="The server is missing some information that is needed for authorization.",
73+
),
74+
500,
75+
)
76+
77+
url = auth_settings.FRONTEND_CALLBACK_URL
78+
parameters = {"token": token["access_token"]}
79+
80+
req = PreparedRequest()
81+
req.prepare_url(url, parameters)
82+
return redirect(req.url, code=302)
83+
84+
85+
class IdentityApi(Resource):
86+
@login_required
87+
def get(self):
88+
identity = get_identity()
89+
return identity
90+
91+
92+
api.add_resource(LoginApi, "/login")
93+
api.add_resource(AuthApi, "/auth")
94+
api.add_resource(IdentityApi, "/identity")

0 commit comments

Comments
 (0)