Skip to content

Commit

Permalink
(PC-31571)[API] feat: discord auth full flow from our form
Browse files Browse the repository at this point in the history
  • Loading branch information
lixxday committed Sep 5, 2024
1 parent a2516f8 commit 23d2b52
Show file tree
Hide file tree
Showing 6 changed files with 192 additions and 155 deletions.
43 changes: 26 additions & 17 deletions api/src/pcapi/connectors/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,30 @@
DISCORD_CLIENT_ID = "1261948740915433574"
DISCORD_CLIENT_SECRET = settings.DISCORD_CLIENT_SECRET
DISCORD_CALLBACK_URI = f"{settings.API_URL}/auth/discord/callback"
DISCORD_REDIRECT_SUCCESS = f"{settings.API_URL}/auth/discord/success"
DISCORD_FULL_REDIRECT_URI = (
f"https://discord.com/api/oauth2/authorize"
f"?client_id={DISCORD_CLIENT_ID}"
f"&redirect_uri={DISCORD_CALLBACK_URI}"
f"&response_type=code"
f"&scope=identify%20guilds.join"
)
DISCORD_HOME_URI = f"https://discord.com/channels/{DISCORD_GUILD_ID}/@home"

DISCORD_API_URI = "https://discord.com/api"


def build_discord_redirection_uri(user_id: int) -> str:
base_uri = f"{DISCORD_API_URI}/oauth2/authorize"
client_id = DISCORD_CLIENT_ID
redirect_uri = DISCORD_CALLBACK_URI
response_type = "code"
scope = "identify%20guilds.join"

return f"{base_uri}?client_id={client_id}&redirect_uri={redirect_uri}&response_type={response_type}&scope={scope}&state={user_id}"


def get_user_id(access_token: str) -> str | None:
url = f"{DISCORD_API_URI}/oauth2/@me"
user_response = requests.get(url, headers={"Authorization": f"Bearer {access_token}"})
user_response.raise_for_status()
try:
return user_response.json()["user"]["id"]
except KeyError:
return None


def retrieve_access_token(code: str) -> str | None:
data = {
Expand All @@ -34,19 +48,14 @@ def retrieve_access_token(code: str) -> str | None:
return access_token


def add_to_server(access_token: str) -> None:
def add_to_server(access_token: str, user_discord_id: str) -> None:
"""
Adds the user to the pass culture discord server
Our server is identified by the DISCORD_GUILD_ID
"""
user_response = requests.get(
"https://discord.com/api/users/@me", headers={"Authorization": f"Bearer {access_token}"}
)
user_response.raise_for_status()

user_id = user_response.json()["id"]
data = {"access_token": access_token}
url = f"https://discord.com/api/guilds/{DISCORD_GUILD_ID}/members/{user_id}"
url = f"https://discord.com/api/guilds/{DISCORD_GUILD_ID}/members/{user_discord_id}"
headers = {"Authorization": f"Bot {DISCORD_BOT_TOKEN}", "Content-Type": "application/json"}

requests.put(url, json=data, headers=headers)
response = requests.put(url, json=data, headers=headers)
response.raise_for_status()
93 changes: 66 additions & 27 deletions api/src/pcapi/routes/auth/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,56 +4,109 @@
from flask_wtf.csrf import CSRFProtect
from werkzeug.wrappers.response import Response

from pcapi import repository
from pcapi.connectors import discord as discord_connector
from pcapi.core.users import exceptions as users_exceptions
from pcapi.core.users import models as user_models
from pcapi.core.users import repository as users_repo
from pcapi.models import db
import pcapi.routes.auth.exceptions as auth_exceptions
from pcapi.routes.auth.forms.forms import SigninForm
from pcapi.utils import requests

from . import blueprint
from . import utils


ERROR_STRING_PREFIX = "Erreur d'authentification Discord: "


@blueprint.auth_blueprint.route("/discord/signin", methods=["GET"])
def discord_signin() -> str:
form = SigninForm()
form.discord_id.data = request.args.get("discord_id")
form.redirect_url.data = discord_connector.DISCORD_FULL_REDIRECT_URI
if error_message := request.args.get("error"):
form.error_message = error_message

return render_template("discord_signin.html", form=form)


def redirect_with_error(error_message: str) -> Response:
repository.mark_transaction_as_invalid()
return redirect(f"/auth/discord/signin?error={error_message}", code=303)


def handle_http_error(error: requests.exceptions.HTTPError) -> Response:
error_message = ""
if error.response:
error_message = error.response.json().get("error_description")
if not error_message:
error_message = error.response.text
error_message += "Tu peux réessayer ou contacter le support."
return redirect_with_error(ERROR_STRING_PREFIX + error_message)


@blueprint.auth_blueprint.route("/discord/callback", methods=["GET"])
@repository.atomic()
def discord_call_back() -> str | Response | None:
# Webhook called by the discord server once the discord authentication is successful
code = request.args.get("code")
ERROR_STRING_PREFIX = "Erreur d'authentification Discord: "
user_id = request.args.get("state")

if not code:
return redirect(f"/auth/discord/signin?error={ERROR_STRING_PREFIX}code non récupéré", code=303)
return redirect_with_error(f"{ERROR_STRING_PREFIX}code non récupéré")
if not user_id:
return redirect_with_error(f"{ERROR_STRING_PREFIX}user_id pass Culture non récupéré")

try:
access_token = discord_connector.retrieve_access_token(code)
except requests.exceptions.HTTPError as e:
return redirect(f"/auth/discord/signin?error={ERROR_STRING_PREFIX}{e.response.json().get('error')}", code=303)
return handle_http_error(e)

if not access_token:
return redirect(f"/auth/discord/signin?error={ERROR_STRING_PREFIX}access token non récupéré", code=303)
return redirect_with_error(f"{ERROR_STRING_PREFIX}access token non récupéré")

try:
user_discord_id = discord_connector.get_user_id(access_token)
except requests.exceptions.HTTPError as e:
return handle_http_error(e)

if not user_discord_id:
return redirect_with_error(f"{ERROR_STRING_PREFIX}discord id non récupéré")

try:
update_discord_user(user_id, user_discord_id)
except auth_exceptions.DiscordUserAlreadyLinked:
return redirect_with_error("Ce compte Discord est déjà lié à un autre compte pass Culture.")
except auth_exceptions.UserNotAllowed:
return redirect_with_error("Accès refusé au serveur Discord. Contacte le support pour plus d'informations")

try:
discord_connector.add_to_server(access_token)
discord_connector.add_to_server(access_token, user_discord_id)
except requests.exceptions.HTTPError as e:
return redirect(
f"/auth/discord/signin?error={ERROR_STRING_PREFIX}{e.response.json().get('message')}",
code=303,
)
return handle_http_error(e)

return redirect(discord_connector.DISCORD_HOME_URI, code=303)


def update_discord_user(user_id: str, discord_id: str) -> None:
already_linked_user = user_models.DiscordUser.query.filter_by(discordId=discord_id).first()
if already_linked_user:
raise auth_exceptions.DiscordUserAlreadyLinked()

user = user_models.User.query.get(user_id)
discord_user = user.discordUser

if discord_user is None:
# We still add the user to the database even if he doesn't have access to the discord server
discord_user = user_models.DiscordUser(userId=user.id, discordId=discord_id, hasAccess=False)
db.session.add(discord_user)
raise auth_exceptions.UserNotAllowed()

if not discord_user.hasAccess:
raise auth_exceptions.UserNotAllowed()

discord_user.discordId = discord_id


@blueprint.auth_blueprint.route("/discord/signin", methods=["POST"])
def discord_signin_post() -> str | Response | None:
csrf = CSRFProtect()
Expand All @@ -65,8 +118,6 @@ def discord_signin_post() -> str | Response | None:

email = form.email.data
password = form.password.data
discord_id = form.discord_id.data
url_redirection = form.redirect_url.data

try:
user = users_repo.get_user_with_credentials(email, password, allow_inactive=True)
Expand All @@ -86,17 +137,5 @@ def discord_signin_post() -> str | Response | None:
form.error_message = "Le compte a été anonymisé"
return render_template("discord_signin.html", form=form)

discord_user = user.discordUser
if discord_user is None or not discord_user.hasAccess:
if discord_user is None:
discord_user = user_models.DiscordUser(userId=user.id, discordId=discord_id, hasAccess=False)
db.session.add(discord_user)
db.session.commit()
form.error_message = "Accès refusé au serveur Discord. Contacte le support pour plus d'informations"
return render_template("discord_signin.html", form=form)
if discord_user.is_active:
return redirect(url_redirection)

discord_user.discordId = discord_id
db.session.commit()
url_redirection = discord_connector.build_discord_redirection_uri(user.id)
return redirect(url_redirection)
10 changes: 10 additions & 0 deletions api/src/pcapi/routes/auth/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class DiscordUserAlreadyLinked(Exception):
pass


class UserNotAllowed(Exception):
pass


class DiscordException(Exception):
pass
2 changes: 0 additions & 2 deletions api/src/pcapi/routes/auth/forms/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,4 @@
class SigninForm(PCForm):
email = fields.PCEmailField("Adresse email")
password = fields.PCPasswordField("Mot de passe")
discord_id = fields.PCHiddenField("discord_id")
redirect_url = fields.PCLongHiddenField("redirect_url")
error_message: str = ""
2 changes: 0 additions & 2 deletions api/src/pcapi/routes/auth/templates/discord_signin.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ <h3 class="text-muted">Pour accéder au serveur Discord du pass Culture</h3>
data-turbo="false">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class=" col-10 gap-2 w-2 items-center">
<div>{{ form.redirect_url }}</div>
<div>{{ form.discord_id }}</div>
<div class="mt-2">{{ form.email }}</div>
<div class="mt-2">{{ form.password }}</div>
<div class="mt-2 flex-row justify-center">
Expand Down
Loading

0 comments on commit 23d2b52

Please sign in to comment.