Skip to content

Commit

Permalink
enhancement: add sign in with apple (dongri#60)
Browse files Browse the repository at this point in the history
* enhancement: add sign in with apple

* updates

* get it working

* apple auth working

* pylint

* update

* update secrets
  • Loading branch information
ra0x3 authored Dec 29, 2023
1 parent 254a1ae commit 3060961
Show file tree
Hide file tree
Showing 34 changed files with 682 additions and 664 deletions.
1 change: 1 addition & 0 deletions www/backend/.pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ disable=missing-module-docstring,
duplicate-code,
inconsistent-return-statements,
invalid-name,
too-many-return-statements,


# Enable the message, report, category or checker with the given id(s). You can
Expand Down
1 change: 1 addition & 0 deletions www/backend/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ url = "https://pypi.org/simple"
verify_ssl = true

[packages]
cryptography = "*"
Django = "*"
django-filter = "*"
djangorestframework = "*"
Expand Down
1,010 changes: 374 additions & 636 deletions www/backend/Pipfile.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions www/backend/helpar/helpar/lib/middlewares.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ def __init__(self):
("GET", "/api/health/"),
("GET", "/api/users/auth/google/callback/"),
("POST", "/api/events/uui/"),
("POST", "/api/users/auth/apple/"),
("POST", "/api/users/auth/apple/callback/"),
("POST", "/api/users/auth/google/"),
("POST", "/api/users/auth/phone/"),
("POST", "/api/users/auth/phone/verify/"),
Expand Down
61 changes: 61 additions & 0 deletions www/backend/helpar/helpar/lib/services.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import logging
import base64
import json
import datetime as dt
import jwt
import httpx
from openai import OpenAI
from django.conf import settings
from t import *
Expand Down Expand Up @@ -76,3 +81,59 @@ def query_gpt(prompt: str, faqs: str) -> Optional[str]:
except Exception as err:
logger.error("Error querying model(%s): %s", model, str(err))
return None


class AppleAuthentication:
@staticmethod
def derive_user_email(code: str) -> Optional[str]:
# NOTE: Used when you need to manually create the JWT secret
secret = AppleAuthentication._generate_client_secret()
token = AppleAuthentication._generate_jwt(code, secret)
claims = AppleAuthentication._decode_jwt(token["id_token"])
return claims.get("email")

@staticmethod
def derive_user_email_with_idcode(id_code: str) -> str:
# NOTE: Used when given the JWT secret in the callback
claims = AppleAuthentication._decode_jwt(id_code)
return claims.get("email")

@staticmethod
def _generate_jwt(code: str, secret: str):
url = "https://appleid.apple.com/auth/token"
headers = {"Content-Type": "application/x-www-form-urlencoded"}
data = {
"client_id": settings.APPLE_CLIENT_ID,
"client_secret": secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": settings.APPLE_AUTH_REDIRECT_URI,
}
response = httpx.post(url, data=data, headers=headers)
return response.json()

@staticmethod
def _generate_client_secret():
issued = dt.datetime.utcnow()
expiry = issued + dt.timedelta(hours=1)
payload = {
"iss": settings.APPLE_TEAM_ID,
"iat": int(issued.timestamp()),
"exp": int(expiry.timestamp()),
"aud": "https://appleid.apple.com",
"sub": settings.APPLE_CLIENT_ID,
}

client_secret = jwt.encode(
payload, settings.APPLE_CLIENT_SECRET, algorithm="ES256", headers={"kid": settings.APPLE_TEAM_ID}
)
return client_secret

@staticmethod
def _decode_jwt(token: str):
_, payload, _ = token.split(".")
# Pad to a base64 string
payload += "=" * (4 - len(payload) % 4)
decoded_bytes = base64.urlsafe_b64decode(payload)
decoded_payload = decoded_bytes.decode("utf-8")
return json.loads(decoded_payload)
4 changes: 4 additions & 0 deletions www/backend/helpar/helpar/settings/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,7 @@


OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]

APPLE_CLIENT_ID = os.environ["APPLE_CLIENT_ID"]
APPLE_TEAM_ID = os.environ["APPLE_TEAM_ID"]
APPLE_CLIENT_SECRET = os.environ["APPLE_CLIENT_SECRET"]
3 changes: 3 additions & 0 deletions www/backend/helpar/helpar/settings/development.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@
NGROK_HOST = os.environ["NGROK_HOST"]

GOOGLE_OAUTH_REDIRECT_URI = NGROK_HOST + "/api/users/auth/google/callback/"


APPLE_AUTH_REDIRECT_URI = NGROK_HOST + "/api/users/auth/apple/callback/"
3 changes: 3 additions & 0 deletions www/backend/helpar/helpar/settings/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@


GOOGLE_OAUTH_REDIRECT_URI = "https://api.helpar.app/api/users/auth/google/callback/"


APPLE_AUTH_REDIRECT_URI = "https://api.helpar.app/api/users/auth/apple/callback/"
17 changes: 17 additions & 0 deletions www/backend/helpar/users/migrations/0016_alter_user_jwt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.0 on 2023-12-28 23:33

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("users", "0015_usermetadata_name_usermetadata_sex_and_more"),
]

operations = [
migrations.AlterField(
model_name="user",
name="jwt",
field=models.TextField(max_length=255, unique=True),
),
]
2 changes: 1 addition & 1 deletion www/backend/helpar/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class Meta:
username: models.CharField = models.CharField(max_length=255)
email: models.CharField = models.CharField(max_length=255, null=True, db_index=True)
phone: models.CharField = models.CharField(max_length=255, null=True)
jwt: models.CharField = models.CharField(max_length=255, unique=True)
jwt: models.TextField = models.TextField(max_length=255, unique=True)
updated_at: models.DateTimeField = models.DateTimeField()
created_at: models.DateTimeField = models.DateTimeField(db_index=True)
deleted_at: models.DateTimeField = models.DateTimeField(null=True)
Expand Down
3 changes: 3 additions & 0 deletions www/backend/helpar/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ class UsersRouter(BaseCustomRouter):

router = UsersRouter()
router.route("POST", r"auth/google/", google_auth_view, "google_auth_view")
router.route("POST", r"auth/apple/", apple_auth_view, "apple_auth_view")
router.route("GET", r"auth/google/callback/", google_auth_callback_view, "google_auth_callback_view")
router.route("POST", r"auth/apple/callback/", apple_auth_callback_view, "apple_auth_callback_view")
router.route("POST", r"auth/phone/", twilio_phone_auth_view, "twilio_phone_auth_view")
router.route("POST", r"auth/phone/verify/", twilio_phone_auth_verification_view, "twilio_phone_auth_verification_view")
router.route("POST", r"auth/pkce/", pkce_auth_view, "pkce_auth_view")
router.route("POST", r"apple/service-to-service/", apple_service_to_service_view, "apple_service_to_service_view")

urlpatterns = [
path(r"users/", include(router.urls)),
Expand Down
124 changes: 123 additions & 1 deletion www/backend/helpar/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
import datetime as dt
import json

from django.http import HttpResponseRedirect, HttpResponseServerError
from django import forms
from django.http import HttpResponseRedirect, HttpResponseServerError, HttpResponseBadRequest
from django.conf import settings
from rest_framework import status
from rest_framework.response import Response
Expand All @@ -12,6 +13,7 @@
from users.models import PKCERequest
from t import *
from helpar.lib.utils import *
from helpar.lib.services import AppleAuthentication

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -102,3 +104,123 @@ def pkce_auth_view(request: Request) -> Response:
except Exception as err:
logger.exception("PKCE auth error: %s", str(err))
return Response(data={"success": False, "error": str(err)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)


@api_view(["POST"])
def apple_service_to_service_view(request: Request) -> Response:
return Response(data={"success": False, "error": "Not implemented"}, status=status.HTTP_501_NOT_IMPLEMENTED)


class AppleAuthForm(forms.Form):
state: forms.CharField = forms.CharField(max_length=255)
code: forms.CharField = forms.CharField(widget=forms.Textarea)
id_token: forms.CharField = forms.CharField(widget=forms.Textarea)
user: forms.CharField = forms.CharField(widget=forms.Textarea, required=False)


@api_view(["POST"])
def apple_auth_view(request: Request) -> Response:
origin = request.META.get("HTTP_REFERER", request.META.get("HTTP_ORIGIN"))
state = request.query_params.get("state")
mins_expiry = 5
expiry = timezone.now() + dt.timedelta(minutes=mins_expiry)
try:
_ = OauthRequest.objects.create(
state=state, origin=origin, created_at=timezone.now(), provider="apple", expiry=expiry
)
return Response(data={"success": True}, status=status.HTTP_200_OK)
except Exception as err:
logger.exception("Apple auth error: %s", str(err))
return HttpResponseServerError(
content=json.dumps({"success": False, "details": "Apple auth failed"}),
content_type="application/json",
)


@api_view(["POST"])
def apple_auth_callback_view(request: Request) -> Response:
try:
form = AppleAuthForm(request.data)
if not form.is_valid():
return HttpResponseBadRequest(
content_type="application/json",
content=json.dumps({"success": False, "details": "Apple auth form invalid."}),
)

user = {}
first_name = last_name = email = ""

user = form.cleaned_data["user"]
if user:
user_data = json.loads(user)
first_name = user_data["name"]["firstName"]
last_name = user_data["name"]["lastName"]
email = user_data["email"]

oauth_req = (
OauthRequest.objects.filter(state=form.cleaned_data["state"], deleted_at__isnull=True)
.order_by("-created_at")
.first()
)
if not oauth_req:
return HttpResponseServerError(
content_type="application/json",
content=json.dumps({"success": False, "details": "No oauth request found for state."}),
)
if oauth_req.expiry < timezone.now():
return HttpResponseBadRequest(
content_type="application/json",
content=json.dumps({"success": False, "details": "Apple auth request expired."}),
)

oauth_req.deleted_at = timezone.now()
oauth_req.save()

mins_expiry = 5
code = random_digits()
expiry = timezone.now() + dt.timedelta(minutes=mins_expiry)

if not email or not first_name or not last_name:
derived_email = AppleAuthentication.derive_user_email_with_idcode(form.cleaned_data["id_token"])
if not derived_email:
return HttpResponseBadRequest(
content_type="application/json",
content=json.dumps({"success": False, "details": "Cannot derive Apple email."}),
)

if User.objects.filter(email=derived_email).exists():
user = User.objects.get(email=derived_email)
_ = PKCERequest.objects.create(code=code, expiry=expiry, user_id=user.id, created_at=timezone.now())
return HttpResponseRedirect(oauth_req.origin + "hq?code=" + code)

username = first_name + " " + last_name
if User.objects.filter(email=email, username=username).exists():
user = User.objects.get(email=email, username=username)
_ = PKCERequest.objects.create(code=code, expiry=expiry, user_id=user.id, created_at=timezone.now())
return HttpResponseRedirect(oauth_req.origin + "hq?code=" + code)

user_data = {
"email": email,
"username": username,
"created_at": timezone.now(),
"updated_at": timezone.now(),
}
user = User.objects.create(**user_data)

token = jwt.encode(
{"id": str(user.id), "email": user.email, "username": user.username, "phone": user.phone},
settings.JWT_SECRET,
algorithm=settings.JWT_ALGORITHM,
)
user.jwt = token
user.save()

_ = PKCERequest.objects.create(code=code, expiry=expiry, user_id=user.id, created_at=timezone.now())

return HttpResponseRedirect(oauth_req.origin + "hq?code=" + code)
except Exception as err:
logger.exception("Apple auth callback error: %s", str(err))
return HttpResponseServerError(
content=json.dumps({"success": False, "details": "Apple auth callback failed"}),
content_type="application/json",
)
2 changes: 1 addition & 1 deletion www/backend/scripts/wsgi.bash
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ else

source ./venv/bin/activate

aws secretsmanager get-secret-value --secret-id helpar-be1.env | jq -r '.SecretString' > .env
aws secretsmanager get-secret-value --secret-id helpar-be2.env | jq -r '.SecretString' > .env

eval `ssh-agent -s`
ssh-add /home/ubuntu/.ssh/github_id_ed25519
Expand Down
2 changes: 2 additions & 0 deletions www/frontend/helpar/public/index.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"></script>
<link href="https://fonts.googleapis.com/css?family=Montserrat:200,300,400,500,600,700,800,900" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Inter:200,300,400,500,600,700,800,900" rel="stylesheet">
<link href="https://s3.amazonaws.com/helpar.app/assets/soom/css/swiper.css" rel="stylesheet">
Expand All @@ -12,6 +13,7 @@
<script src="https://player.vimeo.com/api/player.js"></script>
<script src="https://unpkg.com/swiper@8/swiper-bundle.min.js.map"> </script>
<script src="https://unpkg.com/swiper@8/swiper-bundle.min.js"> </script>

<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<meta
name="description"
Expand Down
6 changes: 3 additions & 3 deletions www/frontend/helpar/src/components/TileGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const MediumBox = (props: BoxProps): React.JSX.Element => {
<div className="flex flex-row h-full justify-evenly">
<div className="bg-white opacity-75 h-8 w-8 flex items-center justify-center rounded-25">
<LogoFacebook
color={{ color: "#2b2016" }}
style={{ color: "#2b2016" }}
height="20px"
width="20px"
onClick={() => {
Expand All @@ -60,7 +60,7 @@ const MediumBox = (props: BoxProps): React.JSX.Element => {
</div>
<div className="bg-white opacity-75 h-8 w-8 flex items-center justify-center rounded-25">
<LogoInstagram
color={{ color: "#2b2016" }}
style={{ color: "#2b2016" }}
height="20px"
width="20px"
onClick={() => {
Expand All @@ -72,7 +72,7 @@ const MediumBox = (props: BoxProps): React.JSX.Element => {
</div>
<div className="bg-white opacity-75 h-8 w-8 flex items-center justify-center rounded-25">
<LogoTwitter
color={{ color: "#2b2016" }}
style={{ color: "#2b2016" }}
height="20px"
width="20px"
onClick={() => {
Expand Down
2 changes: 2 additions & 0 deletions www/frontend/helpar/src/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ class Config {
public environment: Environment;
public development: EnvironmentConfig;
public production: EnvironmentConfig;
public apple_client_id: string = process.env
.REACT_APP_APPLE_CLIENT_ID as string;

constructor() {
this.name = "helpar";
Expand Down
4 changes: 4 additions & 0 deletions www/frontend/helpar/src/global.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ type Nullable<T> = T | null;
type Undefined<T> = T | undefined;

type Json = any;

interface Window {
AppleID: any;
}
4 changes: 3 additions & 1 deletion www/frontend/helpar/src/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ export const alertMessages = (alert: Nullable<string>): Nullable<string> => {
return "Doesn't look like you're on a branded experience.";
case "phone":
return "A valid phone number is required for this experience.";
case "auth":
case "expired":
return "Your session seems to have expired. Please log in again.";
case "auth":
return "Looks like we couldn't log you in. Please try again.";
default:
return null;
}
Expand Down
2 changes: 1 addition & 1 deletion www/frontend/helpar/src/views/About.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const About = (): React.JSX.Element => {
}

if (!session.current.isValid()) {
return navigate("/?alert=auth");
return navigate("/?alert=expired");
}

if (!cachedBrand.current.isValid()) {
Expand Down
Loading

0 comments on commit 3060961

Please sign in to comment.