Skip to content

Commit

Permalink
Merge pull request #1 from observatorycontrolsystem/update_model
Browse files Browse the repository at this point in the history
Update model
  • Loading branch information
jnation3406 committed Jan 14, 2022
2 parents 4c03326 + 40a9ed0 commit 587ebed
Show file tree
Hide file tree
Showing 15 changed files with 224 additions and 60 deletions.
10 changes: 10 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[run]
source = ocs_authentication

[report]
omit =
*/migrations/*
*/management/*
*settings.py
*wsgi.py
*apps.py
37 changes: 29 additions & 8 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.7, 3.8, 3.9]
python-version: [3.7, 3.8, 3.9, "3.10"]
steps:
- name: Check out repository
uses: actions/checkout@v2
Expand All @@ -30,11 +30,32 @@ jobs:
pip install .
- name: Run tests
run: coverage run manage.py test --settings=test_settings

# TODO: Enable sending coverage report
- name: Generate and send coveralls report
run: coveralls --service=github
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

# - name: Generate and send coveralls report
# run: coveralls --service=github
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

publish_to_pypi:
# Only run this job if the run_tests job has succeeded, and if
# this workflow was triggered by the creation of a new tag
needs: run_tests
if: github.event_name == 'create' && github.event.ref_type == 'tag' && github.event.repository.fork == false
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.9'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
- name: Build package and publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_OCS_AUTHENTICATION_API_TOKEN }}
run: |
python setup.py sdist bdist_wheel
twine upload dist/*
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# OCS Authentication

![Build](https://github.com/observatorycontrolsystem/ocs-authentication/workflows/Build/badge.svg)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/fbba450da5394be0bd626918bbc28788)](https://www.codacy.com/gh/observatorycontrolsystem/ocs-authentication/dashboard?utm_source=github.com&utm_medium=referral&utm_content=observatorycontrolsystem/ocs-authentication&utm_campaign=Badge_Grade)
[![Coverage Status](https://coveralls.io/repos/github/observatorycontrolsystem/ocs-authentication/badge.svg)](https://coveralls.io/github/observatorycontrolsystem/ocs-authentication)

Authentication backends and utilities for the OCS.

For the OCS, the authorization server is the Observation Portal.
Expand Down Expand Up @@ -51,7 +55,8 @@ AUTHENTICATION_BACKENDS = [
### OAuthUsernamePasswordBackend

This backend allows a user to authenticate using username and password with the OAuth authorization server. This backend checks whether the user account exists in the authorization server, and if it does, creates or updates that user account locally. If the intention is to check the local database first for if the user exists before sending a call off to the authorization server, you must add either `ocs_authentication.backends.EmailOrUsernameModelBackend` or `django.contrib.auth.backends.ModelBackend` to the `AUTHENTICATION_BACKENDS` *before* this backend is listed.
```

```python
AUTHENTICATION_BACKENDS = [
...
# 'ocs_authentication.backends.EmailOrUsernameModelBackend', # Add this to check local DB first
Expand All @@ -62,6 +67,43 @@ AUTHENTICATION_BACKENDS = [

Note that if the you want to check the local DB for if the user exists there first, choose either `EmailOrUsernameModelBackend` or `ModelBackend` based on which of these backends is used in the authorization server. Using `EmailOrUsernameModelBackend` in the authorization server but using `ModelBackend` in the client application will mean that any time a user logs in to the client app with their email, the authentication request will always be forwarded to the authorization server even if the user account already exists in the local DB.

### OCSTokenAuthentication Backend

If the client application is using Django REST Framework and should support API token authentication, switch out use of REST Framework's TokenAuthentication with this backend which performs TokenAuthentication on the authtoken and then falls back on the api_token within the AuthProfile model. It can be included by updating the following in the settings:

```python
REST_FRAMEWORK = {
...
'DEFAULT_AUTHENTICATION_CLASSES': (
# Allows authentication against DRF authtoken and then Oauth Server's api_token
'ocs_authentication.backends.OCSTokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
...
}
```

### IsServer Permission

This permission is used to allow the OAuth server to call views within other applications, using its `OAUTH_SERVER_KEY`. This key should be kept private and only known by the applications and Oauth server. This permission should be included as the permission class on any view you want only accessible by the OAuth server.

### IsAdminOrReadOnly Permission

This permission is used to specify that a user has read-only access to the safe endpoints if unauthenticated (like `GET`), and must be an admin user (`is_staff=True`) to access writable endpoints (like `POST` or `PUT`). This should be added to individual viewset classes as needed.

### Views

This view is used by client applications to allow the OAuth server application to update the API token of a user when that user revokes their token and generates a new one. This keeps the tokens in sync, so the user can use the same API token to authenticate any client application. To include this view in your client app, add this line to your `urlpatterns` in `urls.py`:

```
from django.conf.urls import url, include
import ocs_authentication.auth_profile.urls as authprofile_urls
url(r'^authprofile/', include(authprofile_urls))
```

You must also set the environment variable `OAUTH_CLIENT_APPS_BASE_URLS` in the Oauth Server, which will trigger the server to call the UpdateToken View on each of those URLs whenever a user's token is revoked and replaced, and the AddUpdateUser View on each of those URLs whenever a user model or profile is created or updated. This keeps the user account details and api_tokens synced up between applications.

## Settings

All settings for this library are namespaced under the `OCS_AUTHENTICATION` dictionary. In your settings file:
Expand Down Expand Up @@ -91,6 +133,11 @@ Default: `''`

The OAuth client secret for the OAuth application in the authorization server used to generate tokens via username and password.

### OAUTH_SERVER_KEY
Default: `''`

The OAuth server key is used for OAuth client applications to authenticate that a request to update the api_token for a user is coming from the OAuth server, and not from some random party. This secret token should be sent in requests in the HTTP header with `Authorization: Server <OAUTH_SERVER_TOKEN>`.

### REQUESTS_TIMEOUT_SECONDS
Default: `60`

Expand Down
2 changes: 1 addition & 1 deletion ocs_authentication/auth_profile/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

class AuthProfileAdmin(admin.ModelAdmin):
model = AuthProfile
list_display = ('user', 'staff_view', 'access_token', 'refresh_token')
list_display = ('user', 'staff_view')
raw_id_fields = ('user',)
search_fields = ['user__username']

Expand Down
7 changes: 3 additions & 4 deletions ocs_authentication/auth_profile/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 3.1.11 on 2021-10-19 20:39
# Generated by Django 3.2.10 on 2021-12-22 18:05

from django.conf import settings
from django.db import migrations, models
Expand All @@ -17,10 +17,9 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name='AuthProfile',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('staff_view', models.BooleanField(default=False)),
('access_token', models.CharField(default='', max_length=255)),
('refresh_token', models.CharField(default='', max_length=255)),
('api_token', models.CharField(default='', max_length=255)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
Expand Down
3 changes: 1 addition & 2 deletions ocs_authentication/auth_profile/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,4 @@
class AuthProfile(models.Model):
user = models.OneToOneField(django_settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
staff_view = models.BooleanField(default=False)
access_token = models.CharField(max_length=255, default='')
refresh_token = models.CharField(max_length=255, default='')
api_token = models.CharField(max_length=255, default='')
8 changes: 5 additions & 3 deletions ocs_authentication/auth_profile/test_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ def setUp(self) -> None:
'profile': {
'staff_view': False,
},
'is_staff': False
'tokens': {
'api_token': '1234'
},
'is_staff': False,
'is_superuser': False
}
password = 'qwerty'
self.token_response = {
Expand All @@ -57,8 +61,6 @@ def check_user(self, user, profile_response=None, token_response=None):
profile_response = self.profile_response
if token_response is None:
token_response = self.token_response
self.assertEqual(user.authprofile.access_token, token_response['access_token'])
self.assertEqual(user.authprofile.refresh_token, token_response['refresh_token'])
self.assertEqual(user.authprofile.staff_view, profile_response['profile']['staff_view'])
self.assertEqual(user.is_staff, profile_response['is_staff'])
self.assertEqual(user.first_name, profile_response['first_name'])
Expand Down
8 changes: 8 additions & 0 deletions ocs_authentication/auth_profile/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.conf.urls import url

from ocs_authentication.auth_profile.views import AddUpdateUserView


urlpatterns = [
url(r'^addupdateuser/$', AddUpdateUserView.as_view(), name='add_update_user')
]
35 changes: 35 additions & 0 deletions ocs_authentication/auth_profile/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import json
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status

from ocs_authentication.permissions import IsServer
from ocs_authentication.util import create_or_update_user, Profile


class AddUpdateUserView(APIView):
"""
This view is meant to be called by the Oauth Server when a new user account is created. This will create
the corresponding user account within this Oauth client app and give it the same api-token, so the user
can access this application with their api-token without needing to initially login with their password.
This should also be called on token change or on any user info change.
"""
permission_classes = [IsServer]

def post(self, request):
data = json.loads(request.body.decode('utf-8'))
profile = Profile(
data.get('first_name', ''),
data.get('last_name', ''),
data.get('username', ''),
data.get('email', ''),
data.get('tokens', {}).get('api_token', ''),
data.get('is_staff', False),
data.get('is_superuser', False),
data.get('profile', {}).get('staff_view', False)
)
# The password will not be set here since this only has the profile api info.
# The password will only get set when logging in using username/password auth
# which is forwarded through Oauth.
create_or_update_user(profile, password=None)
return Response({'message': 'User account updated'}, status=status.HTTP_200_OK)
33 changes: 30 additions & 3 deletions ocs_authentication/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,38 @@
from django.core.validators import validate_email
from django.contrib.auth.backends import ModelBackend, BaseBackend
from django.core.exceptions import ValidationError, PermissionDenied

from django.utils.translation import gettext as _
from rest_framework.authentication import TokenAuthentication, exceptions
from ocs_authentication.util import generate_tokens, get_profile, create_or_update_user
from ocs_authentication.auth_profile.models import AuthProfile
from ocs_authentication.exceptions import ProfileException, OAuthTokenException


class OCSTokenAuthentication(TokenAuthentication):
"""
This Allows authentication based on the api_key stored in the AuthProfile model.
This should allow users to use the same api_key between client apps and the Oauth Server.
TODO:: Once we switch to just using the DRF tokens rather than allowing both DRF tokens and
the AuthProfile api_tokens, this backend should no longer be necessary.
"""
def authenticate_credentials(self, key):
try:
output = super().authenticate_credentials(key)
return output
except exceptions.AuthenticationFailed:
pass
# Fallback on trying the api_token in the AuthToken model
try:
token = AuthProfile.objects.select_related('user').get(api_token=key)
except AuthProfile.DoesNotExist:
raise exceptions.AuthenticationFailed(_('Invalid token.'))

if not token.user.is_active:
raise exceptions.AuthenticationFailed(_('User inactive or deleted.'))

return (token.user, token)


class OAuthUsernamePasswordBackend(ModelBackend):
"""
Authenticate against the OAuth Authorization server using
Expand All @@ -23,12 +50,12 @@ def authenticate(self, request, username=None, password=None):
return None

try:
profile = get_profile(access_token)
profile = get_profile(access_token=access_token)
except ProfileException:
# Failed to get profile data using newly created access token. Something is wrong, indicate not authorized.
raise PermissionDenied('Failed to access user profile')

return create_or_update_user(profile, password, access_token, refresh_token)
return create_or_update_user(profile, password)

def get_user(self, user_id):
try:
Expand Down
31 changes: 31 additions & 0 deletions ocs_authentication/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@

from rest_framework import permissions
from django.conf import settings


class IsAdminOrReadOnly(permissions.BasePermission):
"""The request is either read-only, or the user is staff"""
def has_permission(self, request, view):
return bool(
request.method in permissions.SAFE_METHODS
or request.user and request.user.is_staff
)


class IsServer(permissions.BasePermission):
message = 'Invalid or missing API Key.'

def has_permission(self, request, view):
authorization = request.META.get("HTTP_AUTHORIZATION")

key = ''
if authorization:
try:
_, key = authorization.split("Server ")
except ValueError:
pass

if key:
return key == settings.OCS_AUTHENTICATION['OAUTH_SERVER_KEY']

return False
1 change: 1 addition & 0 deletions ocs_authentication/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
'OAUTH_PROFILE_URL': '',
'OAUTH_CLIENT_ID': '',
'OAUTH_CLIENT_SECRET': '',
'OAUTH_SERVER_KEY': '',
'REQUESTS_TIMEOUT_SECONDS': 60
}

Expand Down
Loading

0 comments on commit 587ebed

Please sign in to comment.