Skip to content

Commit

Permalink
OAuth2.0 with github and 42intra (#80)
Browse files Browse the repository at this point in the history
* OAuth2.0 with github and 42intra
  • Loading branch information
a-levra authored Jan 24, 2024
1 parent b8bf13c commit 1b9904d
Show file tree
Hide file tree
Showing 11 changed files with 345 additions and 10 deletions.
6 changes: 5 additions & 1 deletion doc/Documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ this documentation details the different endpoints of each microservice.
> ### [/user/refresh-access-jwt](../user_management/doc/User_management.md#refresh-access-jwt)
> ### [/user/{user_id}](../user_management/doc/User_management.md#useruser_id)
> ### [/user/{user_id}](../user_management/doc/User_management.md#useruser-id)
> ### [/search-username/](../user_management/doc/User_management.md#search-username)
> ### [/user/oauth/{oauth-service}](../user_management/doc/User_management.md#oauthoauth-service)
> ### [/user/oauth/callback/{oauth-service}](../user_management/doc/User_management.md#oauthcallbackauth-service)
6 changes: 6 additions & 0 deletions user_management/.env-example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#To receive a github client ID + secret, you need to log in to github and then register your app here: github.com/settings/applications/new
GITHUB_CLIENT_ID=xxxx
GITHUB_CLIENT_SECRET=xxx
#To receive a 42 api client ID + secret, you need to log in to 42intra and then register your app here: profile.intra.42.fr/oauth/applications/new
FT_API_CLIENT_ID=xxx
FT_API_CLIENT_SECRET=xxx
62 changes: 60 additions & 2 deletions user_management/doc/User_management.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ will return an access token when successful
all fields are mandatory
> ``` javascript
> {
> "refresh_token": "234235sfs3r2.."
> "refresh_jwt": "234235sfs3r2.."
> }
> ```
Expand Down Expand Up @@ -326,7 +326,8 @@ all fields are mandatory
</details>
## `user/{user_id}`
## `user/{user-id}`
### Get user non-sensitive information
will return a user object when successful
Expand Down Expand Up @@ -402,3 +403,60 @@ will return a list of usernames that contains the searched username
> - An unexpected error occurred
> - No username found
</details>
## `oauth/{oauth-service}`
### Initiate OAuth authentication for the specified service
This endpoint initiates the OAuth authentication process for the specified authentication service.
It returns a redirection URL to the OAuth service's authorization endpoint.
<details>
<summary><code>GET</code><code><b>/oauth/{auth_service}/</b></code></summary>
### Parameters
#### In the URL (mandatory)
{auth_service}
>
> NB: `auth_service` must be one of the following values: 'github', 'local-test', '42api'
>
#### Responses
> | http code | content-type | response |
> |-----------|--------------------|------------------------------------------------------------------------------------------------------------------------|
> | `200` | `application/json` | `{"redirection_url": "https://oauth-service.com/authorize?client_id=XXX&redirect_uri=YYY&state=ZZZ&scope=user:email"}` |
> | `400` | `application/json` | `{"errors": ["Unknown auth service"]}` |
</details>
## `oauth/callback/{auth-service}`
### OAuth Callback for the specified service
This endpoint handles the callback after successful OAuth authentication and retrieves the user's information.
<details>
<summary><code>GET</code><code><b>/oauth/callback/{auth_service}/</b></code></summary>
### Parameters
#### In the URL (mandatory)
{auth_service}
>
> NB: `auth_service` must be one of the following values: 'github', '42api'
>
#### In the Query Parameters (mandatory)
- `code`: Authorization code obtained from the OAuth service
- `state`: State parameter to prevent CSRF attacks
#### Responses
> | http code | content-type | response |
> |-----------|--------------------|------------------------------------------------------|
> | `201` | `application/json` | `{"refresh_token": "XXXXX"}` |
> | `400` | `application/json` | `{"errors": ["Failed to retrieve access token"]}` |
> | `400` | `application/json` | `{"errors": ["Invalid state"]}` |
> | `400` | `application/json` | `{"errors": ["Failed to create or get user"]}` |
> | `400` | `application/json` | `{"errors": ["An unexpected error occurred : ..."]}` |
> | `500` | `application/json` | `{"errors": ['Failed to create or get user']}` |
</details>
4 changes: 3 additions & 1 deletion user_management/docker/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ Django==4.2.7
gunicorn==21.2.0
idna==3.4
packaging==23.2
pillow==10.2.0
psycopg==3.1.12
psycopg-binary==3.1.12
pycparser==2.21
PyJWT==2.8.0
python-dotenv==1.0.0
requests==2.31.0
sqlparse==0.4.4
typing_extensions==4.8.0
urllib3==2.0.7
urllib3==2.0.7
8 changes: 7 additions & 1 deletion user_management/src/user/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
class User(models.Model):
username = models.CharField(max_length=settings.USERNAME_MAX_LENGTH, unique=True)
elo = models.IntegerField(default=settings.ELO_DEFAULT)
password = models.CharField(max_length=settings.PASSWORD_MAX_LENGTH)
password = models.CharField(max_length=settings.PASSWORD_MAX_LENGTH, null=True)
email = models.EmailField(max_length=settings.EMAIL_MAX_LENGTH, unique=True)
forgotPasswordCode = models.CharField(null=True, max_length=settings.FORGOT_PASSWORD_CODE_MAX_LENGTH)
forgotPasswordCodeExpiration = models.DateTimeField(null=True)
avatar = models.ImageField(null=True, upload_to='avatars/')


class PendingOAuth(models.Model):
hashed_state = models.CharField(max_length=settings.OAUTH_STATE_MAX_LENGTH, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
18 changes: 14 additions & 4 deletions user_management/src/user/urls.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from django.conf.urls.static import static
from django.urls import path

from .views import (ForgotPasswordChangePasswordView,
ForgotPasswordCheckCodeView, ForgotPasswordSendCodeView,
IsEmailTakenView, IsUsernameTakenView, RefreshJWT,
SearchUsernameView, SignInView, SignUpView, UserIdView)
from user.views.OAuth import BaseOAuth, OAuthCallback
from user.views.views import (ForgotPasswordChangePasswordView,
ForgotPasswordCheckCodeView,
ForgotPasswordSendCodeView, IsEmailTakenView,
IsUsernameTakenView, RefreshJWT,
SearchUsernameView, SignInView, SignUpView,
UserIdView)
from user_management import settings

urlpatterns = [
path('signup/', SignUpView.as_view(), name='signup'),
Expand All @@ -16,5 +21,10 @@
path('forgot-password/change-password/', ForgotPasswordChangePasswordView.as_view(),
name='forgot-password-change-password'),
path('search-username/', SearchUsernameView.as_view(), name='search-username'),
path('oauth/<str:auth_service>', BaseOAuth.as_view(), name='oauth'),
path('oauth/callback/<str:auth_service>', OAuthCallback.as_view(), name='oauth-callback'),
path('<str:user_id>/', UserIdView.as_view(), name='user-id')
]

if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
194 changes: 194 additions & 0 deletions user_management/src/user/views/OAuth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
from abc import ABC, abstractmethod
from datetime import timedelta
from hashlib import sha256

import requests
from django.http import JsonResponse
from django.utils import timezone
from django.views import View

from user.models import PendingOAuth, User
from user_management import settings
from user_management.JWTManager import JWTManager
from user_management.utils import (download_image_from_url,
generate_random_string)


class OAuthFactory:
@staticmethod
def create_oauth_handler(auth_service):
if auth_service == 'github':
return GitHubOAuth()
elif auth_service == '42api':
return FtApiOAuth()
else:
return None


class BaseOAuth(View, ABC):
@staticmethod
def get(request, auth_service):
oauth_handler = OAuthFactory.create_oauth_handler(auth_service)
if oauth_handler:
return oauth_handler.handle_auth(request)
else:
return JsonResponse(data={'errors': ['Unknown auth service']}, status=400)

@staticmethod
def create_pending_oauth():
state = generate_random_string(settings.OAUTH_STATE_MAX_LENGTH)
hashed_state = sha256(str(state).encode('utf-8')).hexdigest()
PendingOAuth.objects.create(hashed_state=hashed_state)
return state

@abstractmethod
def handle_auth(self, request):
pass


class GitHubOAuth(BaseOAuth):
def handle_auth(self, request):
state = self.create_pending_oauth()
authorization_url = self.get_github_authorization_url(state)
return JsonResponse(data={'redirection_url': authorization_url}, status=200)

@staticmethod
def get_github_authorization_url(state):
return (
f"{settings.GITHUB_AUTHORIZE_URL}"
f"?client_id={settings.GITHUB_CLIENT_ID}"
f"&redirect_uri={settings.GITHUB_REDIRECT_URI}"
f"&state={state}"
f"&scope=user:email"
)


class FtApiOAuth(BaseOAuth):
def handle_auth(self, request):
state = self.create_pending_oauth()
authorization_url = self.get_ft_api_authorization_url(state)
return JsonResponse(data={'redirection_url': authorization_url}, status=200)

@staticmethod
def get_ft_api_authorization_url(state):
return (
f"{settings.FT_API_AUTHORIZE_URL}"
f"?client_id={settings.FT_API_CLIENT_ID}"
f"&redirect_uri={settings.FT_API_REDIRECT_URI}"
f"&response_type=code"
f"&state={state}"
)


class OAuthCallback(View):
access_token_url = None
client_id = None
client_secret = None
redirect_uri = None

def set_params(self, auth_service):
if auth_service == 'github':
self.access_token_url = settings.GITHUB_ACCESS_TOKEN_URL
self.client_id = settings.GITHUB_CLIENT_ID
self.client_secret = settings.GITHUB_CLIENT_SECRET
self.redirect_uri = settings.GITHUB_REDIRECT_URI
elif auth_service == '42api':
self.access_token_url = settings.FT_API_ACCESS_TOKEN_URL
self.client_id = settings.FT_API_CLIENT_ID
self.client_secret = settings.FT_API_CLIENT_SECRET
self.redirect_uri = settings.FT_API_REDIRECT_URI

def get(self, request, auth_service):
code = request.GET.get('code')
state = request.GET.get('state')
self.set_params(auth_service)
self.check_and_update_state(state)
access_token = self.get_access_token(code)
if not access_token:
return JsonResponse(data={'errors': ['Failed to retrieve access token']}, status=400)

login, avatar_url, email = self.get_user_infos(access_token, auth_service)
user = self.create_or_get_user(login, email, avatar_url)
if not user:
return JsonResponse(data={'errors': ['Failed to create or get user']}, status=400)

success, refresh_token, errors = JWTManager('refresh').generate_token(user.id)
if not success:
return JsonResponse(data={'errors': errors}, status=400)

return JsonResponse(data={'refresh_token': refresh_token}, status=201)

@staticmethod
def create_or_get_user(login, email, avatar_url):
user = User.objects.filter(email=email).first()

if user is None:
user = User.objects.create(username=login, email=email, password=None)
if not download_image_from_url(avatar_url, user):
return None
user.save()

return user

@staticmethod
def get_user_infos(access_token, auth_service):
if auth_service == 'github':
github_user_profile_url = settings.GITHUB_USER_PROFILE_URL
headers = {
'Accept': 'application/vnd.github+json',
'Authorization': f'Bearer {access_token}',
'X-GitHub-Api-Version': '2022-11-28'
}
response = requests.get(github_user_profile_url, headers=headers)
if response.status_code != 200:
return None
user_profile = response.json()
login = user_profile['login']
avatar_url = user_profile['avatar_url']
email_url = settings.GITHUB_USER_PROFILE_URL + '/emails'
response = requests.get(email_url, headers=headers)
email = response.json()[0]['email']
return login, avatar_url, email
if auth_service == '42api':
ft_api_user_profile_url = settings.FT_API_USER_PROFILE_URL
headers = {
'Accept': 'application/json',
'Authorization': f'Bearer {access_token}',
}
response = requests.get(ft_api_user_profile_url, headers=headers)
if response.status_code != 200:
return None
user_profile = response.json()
login = user_profile['login']
avatar_url = user_profile['image']['link']
email = user_profile['email']
return login, avatar_url, email

@staticmethod
def check_and_update_state(state):
hashed_state = sha256(str(state).encode('utf-8')).hexdigest()
pending_oauth = PendingOAuth.objects.filter(hashed_state=hashed_state).first()
if pending_oauth is None:
return JsonResponse(data={'errors': ['Invalid state']}, status=400)
pending_oauth.delete()
PendingOAuth.objects.filter(created_at__lte=timezone.now() - timedelta(minutes=5)).delete()

def get_access_token(self, code):
payload = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'redirect_uri': self.redirect_uri,
'grant_type': 'authorization_code',
'scope': 'public'
}
headers = {
'Accept': 'application/json'
}

response = requests.post(self.access_token_url, data=payload, headers=headers)
if response.status_code != 200:
return None
access_token = response.json()['access_token']

return access_token
Empty file.
File renamed without changes.
Loading

0 comments on commit 1b9904d

Please sign in to comment.