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

Added support for oauth 2 login and registration #2659

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions .env.example
Expand Up @@ -120,3 +120,11 @@ OTEL_SERVICE_NAME=
# for your instance:
# https://docs.djangoproject.com/en/3.2/ref/settings/#secure-proxy-ssl-header
HTTP_X_FORWARDED_PROTO=false

# Enable logging in and registration with OAuth 2
OAUTH_ACTIVE=false
#OAUTH_NAME="OAuth Provider" # Displayed on Login Button as "Login with OAUTH_NAME"
#OAUTH_CLIENT_ID=""
#OAUTH_CLIENT_SECRET=""
Copy link
Contributor

Choose a reason for hiding this comment

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

This would only work with a single external source though, would it?
Since a user could use any Mastodon instance bookwyrm would need to dynamically register an OAuth client with every Mastodon server once it was used the first time + store that securely in the database.

Copy link
Author

Choose a reason for hiding this comment

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

Right.
The idea is that the bookwyrm server authenticates against a specific mastodon (or other oauth 2.0 provider).
For mastodon specifically, you’d need to use the app registration api to generate these values for your bookwyrm server: https://docs.joinmastodon.org/methods/apps/#create

Copy link
Contributor

Choose a reason for hiding this comment

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

That's what I mean, exactly.
I think without the dynamic possibility of authentication users would get frustrated easily, since they wouldn't understand why only a very specific server could be used for authentication—so you'd potentially lock out people who'd like to use their Mastodon login. I'd argue that's against the spirit of the Fediverse.
(But to be clear: I think it's great you want to add external OAuth login!)

Copy link
Author

Choose a reason for hiding this comment

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

It’s just like any other closed registration server. The idea is to unify login for the users of one service that wants to provide multiple fediverse platforms for their users. The way I am using it is to provide a bookwyrm server for my mastodon users.

Copy link
Contributor

Choose a reason for hiding this comment

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

I understand your use case but I am worried that this is such a specific use-case vs. how OAuth login (especially within the Fediverse) works, where one would generally not expect to be restricted to a single server—since at least that is how I’d interpret the idea of the Fediverse.
So maybe it’s possible to allow any Mastodon server with dynamic registration but add a server config to restrict it to a default Mastodon server as well to cover your use case?

#OAUTH_AUTHORIZE_URL="" # For mastodon use "https://<mastodon domain>/oauth/authorize"
#OAUTH_ACCESS_TOKEN_URL="" # For mastodon use "https://<mastodon domain>/oauth/token"
2 changes: 2 additions & 0 deletions bookwyrm/context_processors.py
Expand Up @@ -28,4 +28,6 @@ def site_settings(request): # pylint: disable=unused-argument
"preview_images_enabled": settings.ENABLE_PREVIEW_IMAGES,
"request_protocol": request_protocol,
"js_cache": settings.JS_CACHE,
"oauth_active": settings.OAUTH_ACTIVE,
"oauth_name": settings.OAUTH_NAME,
}
14 changes: 14 additions & 0 deletions bookwyrm/forms/landing.py
Expand Up @@ -56,6 +56,20 @@ def clean(self):
self.add_error("localname", _("User with this username already exists"))


class OAuthRegisterForm(CustomForm):
class Meta:
model = models.User
fields = ["localname", "email"]
help_texts = {f: None for f in fields}

def clean(self):
"""Check if the username is taken"""
cleaned_data = super().clean()
localname = cleaned_data.get("localname").strip()
if models.User.objects.filter(localname=localname).first():
self.add_error("localname", _("User with this username already exists"))


class InviteRequestForm(CustomForm):
def clean(self):
"""make sure the email isn't in use by a registered user"""
Expand Down
42 changes: 42 additions & 0 deletions bookwyrm/oauth.py
@@ -0,0 +1,42 @@
""" responds to various requests to oauth """
from django.contrib.auth import login
from django.core.exceptions import ObjectDoesNotExist
from django.dispatch import receiver
from django.urls import reverse
from django.shortcuts import render, redirect
from django.template.response import TemplateResponse
from authlib.integrations.django_client import OAuth, OAuthError

from bookwyrm import models
from bookwyrm.settings import DOMAIN

oauth = OAuth()
oauth.register("oauth")
oauth = oauth.oauth


def auth(request):
try:
token = oauth.authorize_access_token(request)
except OAuthError:
data = {}
return TemplateResponse(request, "landing/login.html", data)
acct = oauth.get(
"https://raphus.social/api/v1/accounts/verify_credentials", token=token
)
if acct.status_code == 200:
localname = dict(acct.json())["acct"]
username = "{}@{}".format(localname, DOMAIN)
try:
user = models.User.objects.get(username=username)
except ObjectDoesNotExist:
request.session["oauth-newuser"] = localname
request.session["oauth-newuser-pfp"] = dict(acct.json())["avatar"]
return redirect("oauth-register")
login(request, user)
return redirect("/")


def request_login(request):
redirect_uri = request.build_absolute_uri(reverse("oauth"))
return oauth.authorize_redirect(request, redirect_uri, force_login=True)
11 changes: 11 additions & 0 deletions bookwyrm/settings.py
Expand Up @@ -388,3 +388,14 @@
# Do not change this setting unless you already have an existing
# user with the same username - in which case you should change it!
INSTANCE_ACTOR_USERNAME = "bookwyrm.instance.actor"
OAUTH_ACTIVE = env("OAUTH_ACTIVE", False)
if OAUTH_ACTIVE:
OAUTH_NAME = env("OAUTH_NAME", "OAuth2")
AUTHLIB_OAUTH_CLIENTS = {
"oauth": {
"client_id": env("OAUTH_CLIENT_ID"),
"client_secret": env("OAUTH_CLIENT_SECRET"),
"authorize_url": env("OAUTH_AUTHORIZE_URL"),
"access_token_url": env("OAUTH_ACCESS_TOKEN_URL"),
}
}
8 changes: 7 additions & 1 deletion bookwyrm/templates/landing/login.html
Expand Up @@ -10,10 +10,15 @@ <h1 class="title">{% trans "Log in" %}</h1>
{% if login_form.non_field_errors %}
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
{% endif %}

{% if show_confirmed_email %}
<p class="notification is-success">{% trans "Success! Email address confirmed." %}</p>
{% endif %}
{% if oauth_active %}
<a href="{% url 'oauth-login' %}">
<button class="button is-primary" type="submit">{% trans "Log in with " %}{{oauth_name}}</button>
</a>
{% else %}
<form name="login-confirm" method="post" action="{% url 'login' %}">
{% csrf_token %}
{% if show_confirmed_email %}<input type="hidden" name="first_login" value="true">{% endif %}
Expand All @@ -40,6 +45,7 @@ <h1 class="title">{% trans "Log in" %}</h1>
</div>
</div>
</form>
{% endif %}
</div>

{% if site.allow_registration %}
Expand Down
75 changes: 75 additions & 0 deletions bookwyrm/templates/landing/oauth_register.html
@@ -0,0 +1,75 @@
{% extends 'layout.html' %}
{% load i18n %}

{% block title %}{% trans "Create an Account" %}{% endblock %}

{% block content %}

<h1 class="title">{% trans "Create an Account" %}</h1>
<div class="columns">
<div class="column">
<div class="block">
{% if valid %}
<div>
<form name="register" method="post" action="/register">
{% csrf_token %}
<div class="field">
<label class="label" for="id_localname_register">{% trans "Username:" %}</label>
<div class="control">
<input
type="hidden"
name="localname"
value="{{ username }}"
><em>{{ username }}</em>
<div id="desc_localname_register_panel">
<p class="help">
{% trans "Your username cannot be changed." %}
</p>
</div>
</div>
</div>
<div class="field">
<label class="label" for="id_email_register">{% trans "Email address:" %}</label>
<div class="control">
<input
type="email"
name="email"
maxlength="254"
class="input"
id="id_email_register"
value="{% if register_form.email.value %}{{ register_form.email.value }}{% endif %}"
required
aria-describedby="desc_email_register"
>

{% include 'snippets/form_errors.html' with errors_list=register_form.email.errors id="desc_email_register" %}
</div>
</div>

<input type="hidden" name="preferred_timezone" />

<div class="field">
<div class="control">
<button class="button is-primary" type="submit">
{% trans "Sign Up" %}
</button>
</div>
</div>
</form>
</div>
{% else %}
<div class="content">
<h1 class="title">{% trans "Permission Denied" %}</h1>
<p>{% trans "Sorry!" %}</p>
</div>
{% endif %}
</div>
</div>
<div class="column">
<div class="box">
{% include 'snippets/about.html' %}
</div>
</div>
</div>

{% endblock %}
6 changes: 6 additions & 0 deletions bookwyrm/templates/layout.html
Expand Up @@ -115,6 +115,12 @@
</span>
</a>
</div>
{% elif oauth_active %}
<div class="navbar-item pt-5 pb-0">
<a href="{% url 'oauth-login' %}">
<button class="button is-primary" type="submit">{% trans "Log in with " %}{{ oauth_name }}</button>
</a>
</div>
{% else %}
<div class="navbar-item pt-5 pb-0">
{% if request.path != '/login' and request.path != '/login/' %}
Expand Down
5 changes: 4 additions & 1 deletion bookwyrm/urls.py
Expand Up @@ -4,7 +4,7 @@
from django.urls import path, re_path
from django.views.generic.base import TemplateView

from bookwyrm import settings, views
from bookwyrm import settings, views, oauth
from bookwyrm.utils import regex

USER_PATH = rf"^user/(?P<username>{regex.USERNAME})"
Expand Down Expand Up @@ -81,6 +81,9 @@
re_path(
r"^password-reset/(?P<code>[A-Za-z0-9]+)/?$", views.PasswordReset.as_view()
),
re_path(r"^oauth$", oauth.auth, name="oauth"),
re_path(r"^oauth/login$", oauth.request_login, name="oauth-login"),
re_path(r"^oauth/register$", views.OAuthRegister.as_view(), name="oauth-register"),
# admin
re_path(
r"^settings/dashboard/?$", views.Dashboard.as_view(), name="settings-dashboard"
Expand Down
1 change: 1 addition & 0 deletions bookwyrm/views/__init__.py
Expand Up @@ -164,3 +164,4 @@
summary_add_key,
summary_revoke_key,
)
from .oauth import OAuthRegister
12 changes: 9 additions & 3 deletions bookwyrm/views/landing/register.py
Expand Up @@ -29,7 +29,7 @@ def post(self, request):
if settings.install_mode:
raise PermissionDenied()

if not settings.allow_registration:
if not settings.allow_registration and "oauth-newuser" not in request.session:
invite_code = request.POST.get("invite_code")

if not invite_code:
Expand All @@ -41,7 +41,13 @@ def post(self, request):
else:
invite = None

form = forms.RegisterForm(request.POST)
if "oauth-newuser" in request.session:
newuser = request.POST.get("localname")
if newuser != request.session["oauth-newuser"]:
raise PremissionDenied()
form = forms.OAuthRegisterForm(request.POST)
else:
form = forms.RegisterForm(request.POST)
if not form.is_valid():
data = {
"login_form": forms.LoginForm(),
Expand All @@ -55,7 +61,7 @@ def post(self, request):

localname = form.data["localname"].strip()
email = form.data["email"]
password = form.data["password"]
password = None if "oauth-newuser" in request.session else form.data["password"]
try:
preferred_timezone = pytz.timezone(form.data.get("preferred_timezone"))
except pytz.exceptions.UnknownTimeZoneError:
Expand Down
35 changes: 35 additions & 0 deletions bookwyrm/views/oauth.py
@@ -0,0 +1,35 @@
""" invites when registration is closed """
from functools import reduce
import operator
from urllib.parse import urlencode

from django.contrib.auth.decorators import login_required, permission_required
from django.core.paginator import Paginator
from django.db.models import Q
from django.http import HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.http import require_POST

from bookwyrm import emailing, forms, models
from bookwyrm.settings import PAGE_LENGTH


# pylint: disable= no-self-use
class OAuthRegister(View):
"""use an invite to register"""

def get(self, request):
if request.user.is_authenticated or "oauth-newuser" not in request.session:
return redirect("/")
data = {
"register_form": forms.RegisterForm(),
"username": request.session["oauth-newuser"],
"valid": True,
}
return TemplateResponse(request, "landing/oauth_register.html", data)

# post handling is in views.register.Register
1 change: 1 addition & 0 deletions requirements.txt
@@ -1,4 +1,5 @@
aiohttp==3.8.3
oauthlib==3.2.2
bleach==5.0.1
celery==5.2.7
colorthief==0.2.1
Expand Down