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

OAuth Browsable API Login #188

Open
wants to merge 33 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5f8e3ae
Added Browsable API login using OAuth
jhazentia Dec 18, 2020
837206d
add OAUTH_AUTHORIZATION_URL
jhazentia Dec 18, 2020
9efb757
updated test fixtures, oauth session tests
jhazentia Jan 4, 2021
6aae328
add authorization flow test
jhazentia Jan 8, 2021
05ad1d6
cleanup
jhazentia Jan 8, 2021
7d900e5
dynamic base template log in link
jhazentia Jan 11, 2021
5c3dde7
Added oauth session tests
jhazentia Jan 12, 2021
82254d9
cleanup and fix logout, templates
jhazentia Jan 14, 2021
27d2a37
Merge 'master' into oauth-login
jhazentia Jan 14, 2021
67fa3b1
fix formatting
jhazentia Jan 15, 2021
9f6baf0
update static files
jhazentia Jan 21, 2021
d704b40
fix api template, session settings
jhazentia Jan 22, 2021
b9e4b2c
store email from token; improve tests, logging, settings
jhazentia Jan 25, 2021
2829c2e
delete token if authentication fails, remove log message
jhazentia Jan 26, 2021
168764f
verify oauth callback request origin
jhazentia Jan 26, 2021
c69dffb
test oauth callback origin check, skip check for mock sensor
jhazentia Jan 27, 2021
b6aec25
remove empty file
jhazentia Jan 27, 2021
594580f
remove commented out code
jhazentia Jan 27, 2021
3acaa7e
remove commented out code
jhazentia Jan 27, 2021
0b5c5d0
new test client for oauth, new tests, check uid, remove oauth callbac…
jhazentia Mar 5, 2021
0f5454b
nginx config add client cert dn
jhazentia Mar 5, 2021
5fd832a
removed certs, updated readme, tests create temp certs, improved tests
jhazentia Mar 18, 2021
1d51792
Updated README security information
jhazentia Mar 19, 2021
6792496
consolidated readme security section, misc readme improvements
jhazentia Mar 23, 2021
3180f8a
update nginx version, small readme updates
jhazentia Mar 23, 2021
bb8f57e
Updated documentation, fixed client id check
jhazentia Mar 30, 2021
08ac37d
update README
jhazentia May 12, 2021
3871fed
Merge branch 'master' of https://github.com/NTIA/scos-sensor into oau…
jhazentia May 12, 2021
2079f0d
cleanup conftest from merge
jhazentia May 12, 2021
3bc0efd
uncomment scos-actions in requirements
jhazentia May 13, 2021
c71cfd9
fix uid parse, update readme, add exception, fix env.template file names
jhazentia May 13, 2021
1cb7af4
enable same-origin referrer headers for csrf
jhazentia May 14, 2021
2f536a7
fix formatting
jhazentia May 14, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .travis.yml
Expand Up @@ -28,4 +28,5 @@ before_script:
script:
- black --check ./src
- tox -c ./src/tox.ini -e coverage
- tox -c ./src/tox.ini -e oauth
- docker ps | grep api | grep -q healthy
1 change: 1 addition & 0 deletions docker-compose.yml
Expand Up @@ -48,6 +48,7 @@ services:
- CALLBACK_AUTHENTICATION
- CLIENT_ID
- CLIENT_SECRET
- OAUTH_AUTHORIZATION_URL
- OAUTH_TOKEN_URL
- PATH_TO_CLIENT_CERT
- PATH_TO_VERIFY_CERT
Expand Down
5 changes: 3 additions & 2 deletions env.template
Expand Up @@ -60,15 +60,16 @@ MANAGER_IP="$(hostname -I | cut -d' ' -f1)"
# Set to OAUTH if using OAuth Password Flow Authentication, callback url needs to be api/v2/results
CALLBACK_AUTHENTICATION=TOKEN

CLIENT_ID=sensor01.sms.internal
CLIENT_ID=sensor01.sms.internal # must match FQDN
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you not just use socket.getfqdn(). Will that not work in Docker?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not able to get the right value in docker

CLIENT_SECRET=sensor-secret

OAUTH_TOKEN_URL=https://scosmgrqa01.sms.internal:443/authserver/oauth/token
OAUTH_AUTHORIZATION_URL=https://scosmgrqa01.sms.internal:443/authserver/oauth/authorize
# Sensor certificate with private key used as client cert
PATH_TO_CLIENT_CERT=test/sensor01.pem
# Trusted Certificate Authority certificate to verify authserver and callback URL server certificate
PATH_TO_VERIFY_CERT=test/scos_test_ca.crt
# Path relative to configs/certs
PATH_TO_JWT_PUBLIC_KEY=test/jwt_pubkey.pem
# set to JWT to enable JWT authentication
# set to OAUTH to enable OAUTH authentication
AUTHENTICATION=TOKEN
5 changes: 5 additions & 0 deletions nginx/conf.template
Expand Up @@ -23,6 +23,9 @@ server {
listen [::]:443 ssl;
server_name ${DOMAINS};

# https://www.nginx.com/blog/http-strict-transport-security-hsts-and-nginx/
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

# reduce "upstream response is buffered to a temporary file" warnings
proxy_buffers 16 16k;
proxy_buffer_size 16k;
Expand Down Expand Up @@ -50,6 +53,8 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
proxy_pass http://wsgi-server;
}
Expand Down
135 changes: 93 additions & 42 deletions src/authentication/auth.py
@@ -1,3 +1,4 @@
import json
import logging

import jwt
Expand All @@ -14,7 +15,12 @@
in settings.REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"]
)
oauth_jwt_authentication_enabled = (
"authentication.auth.OAuthJWTAuthentication"
"authentication.auth.OAuthAPIJWTAuthentication"
in settings.REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"]
)

oauth_session_authentication_enabled = (
"authentication.auth.OAuthSessionAuthentication"
in settings.REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"]
)

Expand All @@ -28,7 +34,64 @@ def jwt_request_has_required_role(request):
return False


class OAuthJWTAuthentication(authentication.BaseAuthentication):
def decode_token(token):
public_key = ""
try:
with open(settings.PATH_TO_JWT_PUBLIC_KEY) as public_key_file:
public_key = public_key_file.read()
except Exception as e:
logger.error(e)
if not public_key:
error = exceptions.AuthenticationFailed(
"Unable to get public key to decode jwt"
)
logger.error(error)
raise error
try:
# decode JWT token
# verifies jwt signature using RS256 algorithm and public key
# requires exp claim to verify token is not expired
# decodes and returns base64 encoded payload
return jwt.decode(
token,
public_key,
verify=True,
algorithms="RS256",
options={"require": ["exp"], "verify_exp": True},
)
except ExpiredSignatureError as e:
logger.error(e)
raise exceptions.AuthenticationFailed("Token is expired!")
except InvalidSignatureError as e:
logger.error(e)
raise exceptions.AuthenticationFailed("Unable to verify token!")
except Exception as e:
logger.error(e)
raise exceptions.AuthenticationFailed(f"Unable to decode token! {e}")


def get_or_create_user_from_token(decoded_token):
jwt_username = decoded_token["user_name"]
user_model = get_user_model()
user = None
try:
user = user_model.objects.get(username=jwt_username)
except user_model.DoesNotExist:
user = user_model.objects.create_user(username=jwt_username)
user.email = decoded_token["userDetails"]["email"]
user.save()
if decoded_token["authorities"]:
authorities = decoded_token["authorities"]
if settings.REQUIRED_ROLE.upper() in authorities:
user.is_staff = True
# user.is_superuser = True
else:
user.is_staff = False
user.save()
return user


class OAuthAPIJWTAuthentication(authentication.BaseAuthentication):
def authenticate(self, request):
auth_header = get_authorization_header(request)
if not auth_header:
Expand All @@ -42,45 +105,33 @@ def authenticate(self, request):
return None # attempt other configured authentication methods
token = auth_header[1]
# get JWT public key
public_key = ""
decoded_token = decode_token(token)
user = get_or_create_user_from_token(decoded_token)
logger.info("user from token: " + str(user.email))
return (user, decoded_token)


class OAuthSessionAuthentication(authentication.BaseAuthentication):
"""
Use OAuth session for authentication.
"""

def authenticate(self, request):
"""
Returns a `User` if the request session currently has a logged in user.
Otherwise returns `None`.
"""

if not "oauth_token" in request.session:
return None

token = request.session["oauth_token"]
access_token = token["access_token"].encode("utf-8")
try:
with open(settings.PATH_TO_JWT_PUBLIC_KEY) as public_key_file:
public_key = public_key_file.read()
except Exception as e:
logger.error(e)
if not public_key:
error = exceptions.AuthenticationFailed(
"Unable to get public key to decode jwt"
)
logger.error(error)
decoded_token = decode_token(access_token)
except exceptions.AuthenticationFailed as error:
del request.session["oauth_token"]
raise error
try:
# decode JWT token
# verifies jwt signature using RS256 algorithm and public key
# requires exp claim to verify token is not expired
# decodes and returns base64 encoded payload
decoded_key = jwt.decode(
token,
public_key,
verify=True,
algorithms="RS256",
options={"require": ["exp"], "verify_exp": True},
)
except ExpiredSignatureError as e:
logger.error(e)
raise exceptions.AuthenticationFailed("Token is expired!")
except InvalidSignatureError as e:
logger.error(e)
raise exceptions.AuthenticationFailed("Unable to verify token!")
except Exception as e:
logger.error(e)
raise exceptions.AuthenticationFailed(f"Unable to decode token! {e}")
jwt_username = decoded_key["user_name"]
user_model = get_user_model()
user = None
try:
user = user_model.objects.get(username=jwt_username)
except user_model.DoesNotExist:
user = user_model.objects.create_user(username=jwt_username)
user.save()
return (user, decoded_key)
user = get_or_create_user_from_token(decoded_token)
logger.info("user from token: " + str(user.email))
return (user, decoded_token)
1 change: 0 additions & 1 deletion src/authentication/oauth.py
Expand Up @@ -37,7 +37,6 @@ def get_oauth_token():
verify=verify_ssl,
)
oauth.close()
logger.debug("Response from oauth.fetch_token: " + str(token))
return token
except Exception:
raise
Expand Down
12 changes: 12 additions & 0 deletions src/authentication/oauth_urls.py
@@ -0,0 +1,12 @@
from django.conf import settings
from django.contrib.auth import views
from django.urls import path

from authentication.views import oauth_login_callback, oauth_login_view

urlpatterns = (
path("oauth2/", oauth_login_view, name="oauth-login"),
path(f"oauth2/code/{settings.FQDN}", oauth_login_callback, name="oauth-callback"),
# https://github.com/encode/django-rest-framework/blob/master/rest_framework/urls.py
path("oauth2/logout/", views.LogoutView.as_view(), name="oauth-logout"),
)
16 changes: 13 additions & 3 deletions src/authentication/permissions.py
@@ -1,13 +1,23 @@
import logging

from rest_framework import permissions

from .auth import jwt_request_has_required_role, oauth_jwt_authentication_enabled
from .auth import (
jwt_request_has_required_role,
oauth_jwt_authentication_enabled,
oauth_session_authentication_enabled,
)

logger = logging.getLogger(__name__)


class RequiredJWTRolePermissionOrIsSuperuser(permissions.BasePermission):
class JWTRoleOrIsSuperuser(permissions.BasePermission):
message = "User missing required role"

def has_permission(self, request, view):
if oauth_jwt_authentication_enabled and jwt_request_has_required_role(request):
if (
oauth_jwt_authentication_enabled or oauth_session_authentication_enabled
) and jwt_request_has_required_role(request):
return True
if request.user.is_superuser:
return True
Expand Down
2 changes: 1 addition & 1 deletion src/authentication/tests/jwt_content_example.json
Expand Up @@ -9,7 +9,7 @@
"id": null,
"uid": "",
"altSecurityIdenties": null,
"email": "sensor01",
"email": "sensor01@example.com",
"firstname": "sensor01",
"lastname": "",
"cn": "sensor01",
Expand Down
7 changes: 1 addition & 6 deletions src/authentication/tests/test_jwt_auth.py
Expand Up @@ -231,7 +231,7 @@ def test_token_role_user_required_role_accepted(settings, live_server):


@pytest.mark.django_db
def test_token_mulitple_roles_accepted(settings, live_server):
def test_token_multiple_roles_accepted(settings, live_server):
settings.PATH_TO_JWT_PUBLIC_KEY = TEST_JWT_PUBLIC_KEY_FILE
token_payload = get_token_payload(
authorities=["ROLE_MANAGER", "ROLE_USER", "ROLE_ITS"]
Expand Down Expand Up @@ -381,7 +381,6 @@ def test_user_cannot_view_user_detail(settings, live_server):
encoded = jwt.encode(sensor01_token_payload, str(PRIVATE_KEY), algorithm="RS256")
utf8_bytes = encoded.decode("utf-8")
client = RequestsClient()
# authenticating with "ROLE_MANAGER" creates user if does not already exist
response = client.get(
f"{live_server.url}", headers={"Authorization": f"Bearer {utf8_bytes}"}
)
Expand Down Expand Up @@ -411,7 +410,6 @@ def test_user_cannot_view_user_detail_role_change(settings, live_server):
encoded = jwt.encode(sensor01_token_payload, str(PRIVATE_KEY), algorithm="RS256")
utf8_bytes = encoded.decode("utf-8")
client = RequestsClient()
# authenticating with "ROLE_MANAGER" creates user if does not already exist
response = client.get(
f"{live_server.url}", headers={"Authorization": f"Bearer {utf8_bytes}"}
)
Expand Down Expand Up @@ -440,7 +438,6 @@ def test_admin_can_view_user_detail(settings, live_server):
encoded = jwt.encode(token_payload, str(PRIVATE_KEY), algorithm="RS256")
utf8_bytes = encoded.decode("utf-8")
client = RequestsClient()
# authenticating with "ROLE_MANAGER" creates user if does not already exist
response = client.get(
f"{live_server.url}", headers={"Authorization": f"Bearer {utf8_bytes}"}
)
Expand All @@ -464,7 +461,6 @@ def test_admin_can_view_other_user_detail(settings, live_server):
encoded = jwt.encode(sensor01_token_payload, str(PRIVATE_KEY), algorithm="RS256")
utf8_bytes = encoded.decode("utf-8")
client = RequestsClient()
# authenticating with "ROLE_MANAGER" creates user if does not already exist
response = client.get(
f"{live_server.url}", headers={"Authorization": f"Bearer {utf8_bytes}"}
)
Expand Down Expand Up @@ -494,7 +490,6 @@ def test_token_hidden(settings, live_server):
encoded = jwt.encode(token_payload, str(PRIVATE_KEY), algorithm="RS256")
utf8_bytes = encoded.decode("utf-8")
client = RequestsClient()
# authenticating with "ROLE_MANAGER" creates user if does not already exist
response = client.get(
f"{live_server.url}", headers={"Authorization": f"Bearer {utf8_bytes}"}
)
Expand Down