609 lines
22 KiB
Python
609 lines
22 KiB
Python
from datetime import timedelta
|
|
from urllib.parse import quote
|
|
|
|
import django_rq
|
|
from django import forms
|
|
from django.conf import settings
|
|
from django.contrib import auth, messages
|
|
from django.contrib.auth import authenticate
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.core.cache import cache
|
|
from django.core.exceptions import BadRequest, ObjectDoesNotExist
|
|
from django.core.mail import send_mail
|
|
from django.core.signing import TimestampSigner, b62_decode, b62_encode
|
|
from django.core.validators import EmailValidator
|
|
from django.db.models import Count, Q
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.views.decorators.http import require_http_methods
|
|
from loguru import logger
|
|
|
|
from common.config import *
|
|
from common.utils import AuthedHttpRequest
|
|
from journal.models import remove_data_by_user
|
|
from mastodon import mastodon_request_included
|
|
from mastodon.api import *
|
|
from mastodon.api import verify_account
|
|
from takahe.utils import Takahe
|
|
|
|
from .models import Preference, User
|
|
from .tasks import *
|
|
|
|
# the 'login' page that user can see
|
|
require_http_methods(["GET"])
|
|
|
|
|
|
def login(request):
|
|
selected_site = request.GET.get("site", default="")
|
|
|
|
cache_key = "login_sites"
|
|
sites = cache.get(cache_key, [])
|
|
if not sites:
|
|
sites = list(
|
|
User.objects.filter(is_active=True)
|
|
.values("mastodon_site")
|
|
.annotate(total=Count("mastodon_site"))
|
|
.order_by("-total")
|
|
.values_list("mastodon_site", flat=True)
|
|
)
|
|
cache.set(cache_key, sites, timeout=3600 * 8)
|
|
# store redirect url in the cookie
|
|
if request.GET.get("next"):
|
|
request.session["next_url"] = request.GET.get("next")
|
|
invite_status = -1 if settings.INVITE_ONLY else 0
|
|
if settings.INVITE_ONLY and request.GET.get("invite"):
|
|
if Takahe.verify_invite(request.GET.get("invite")):
|
|
invite_status = 1
|
|
request.session["invite"] = request.GET.get("invite")
|
|
else:
|
|
invite_status = -2
|
|
return render(
|
|
request,
|
|
"users/login.html",
|
|
{
|
|
"sites": sites,
|
|
"scope": quote(settings.MASTODON_CLIENT_SCOPE),
|
|
"selected_site": selected_site,
|
|
"allow_any_site": settings.MASTODON_ALLOW_ANY_SITE,
|
|
"invite_status": invite_status,
|
|
},
|
|
)
|
|
|
|
|
|
# connect will send verification email or redirect to mastodon server
|
|
def connect(request):
|
|
if request.method == "POST" and request.POST.get("method") == "email":
|
|
login_email = request.POST.get("email", "")
|
|
try:
|
|
EmailValidator()(login_email)
|
|
except Exception:
|
|
return render(
|
|
request,
|
|
"common/error.html",
|
|
{"msg": _("Invalid email address")},
|
|
)
|
|
user = User.objects.filter(email__iexact=login_email).first()
|
|
code = b62_encode(random.randint(pow(62, 4), pow(62, 5) - 1))
|
|
cache.set(f"login_{code}", login_email, timeout=60 * 15)
|
|
request.session["login_email"] = login_email
|
|
action = "login" if user else "register"
|
|
django_rq.get_queue("mastodon").enqueue(
|
|
send_verification_link,
|
|
user.pk if user else 0,
|
|
action,
|
|
login_email,
|
|
code,
|
|
)
|
|
return render(
|
|
request,
|
|
"common/verify.html",
|
|
{
|
|
"msg": _("Verification"),
|
|
"secondary_msg": _(
|
|
"Verification email is being sent, please check your inbox."
|
|
),
|
|
"action": action,
|
|
},
|
|
)
|
|
login_domain = (
|
|
request.session["swap_domain"]
|
|
if request.session.get("swap_login")
|
|
else (request.POST.get("domain") or request.GET.get("domain"))
|
|
)
|
|
if not login_domain:
|
|
return render(
|
|
request,
|
|
"common/error.html",
|
|
{
|
|
"msg": _("Missing instance domain"),
|
|
"secondary_msg": "",
|
|
},
|
|
)
|
|
login_domain = (
|
|
login_domain.strip().lower().split("//")[-1].split("/")[0].split("@")[-1]
|
|
)
|
|
try:
|
|
app = get_or_create_fediverse_application(login_domain)
|
|
if app.api_domain and app.api_domain != app.domain_name:
|
|
login_domain = app.api_domain
|
|
login_url = get_mastodon_login_url(app, login_domain, request)
|
|
request.session["mastodon_domain"] = app.domain_name
|
|
resp = redirect(login_url)
|
|
resp.set_cookie("mastodon_domain", app.domain_name)
|
|
return resp
|
|
except Exception as e:
|
|
return render(
|
|
request,
|
|
"common/error.html",
|
|
{
|
|
"msg": _("Error connecting to instance"),
|
|
"secondary_msg": f"{login_domain} {e}",
|
|
},
|
|
)
|
|
|
|
|
|
# mastodon server redirect back to here
|
|
@require_http_methods(["GET"])
|
|
@mastodon_request_included
|
|
def connect_redirect_back(request):
|
|
code = request.GET.get("code")
|
|
if not code:
|
|
return render(
|
|
request,
|
|
"common/error.html",
|
|
{
|
|
"msg": _("Authentication failed"),
|
|
"secondary_msg": _("Invalid response from Fediverse instance."),
|
|
},
|
|
)
|
|
site = request.session.get("mastodon_domain")
|
|
if not site:
|
|
return render(
|
|
request,
|
|
"common/error.html",
|
|
{
|
|
"msg": _("Authentication failed"),
|
|
"secondary_msg": _("Invalid cookie data."),
|
|
},
|
|
)
|
|
try:
|
|
token, refresh_token = obtain_token(site, request, code)
|
|
except ObjectDoesNotExist:
|
|
raise BadRequest(_("Invalid instance domain"))
|
|
if not token:
|
|
return render(
|
|
request,
|
|
"common/error.html",
|
|
{
|
|
"msg": _("Authentication failed"),
|
|
"secondary_msg": _("Invalid token from Fediverse instance."),
|
|
},
|
|
)
|
|
|
|
if (
|
|
request.session.get("swap_login", False) and request.user.is_authenticated
|
|
): # swap login for existing user
|
|
return swap_login(request, token, site, refresh_token)
|
|
|
|
user: User = authenticate(request, token=token, site=site) # type: ignore
|
|
if user: # existing user
|
|
user.mastodon_token = token
|
|
user.mastodon_refresh_token = refresh_token
|
|
user.save(update_fields=["mastodon_token", "mastodon_refresh_token"])
|
|
return login_existing_user(request, user)
|
|
else: # newly registered user
|
|
code, user_data = verify_account(site, token)
|
|
if code != 200 or user_data is None:
|
|
return render(
|
|
request,
|
|
"common/error.html",
|
|
{
|
|
"msg": _("Authentication failed"),
|
|
"secondary_msg": _("Invalid account data from Fediverse instance."),
|
|
},
|
|
)
|
|
return register_new_user(
|
|
request,
|
|
username=None
|
|
if settings.MASTODON_ALLOW_ANY_SITE
|
|
else user_data["username"],
|
|
mastodon_username=user_data["username"],
|
|
mastodon_id=user_data["id"],
|
|
mastodon_site=site,
|
|
mastodon_token=token,
|
|
mastodon_refresh_token=refresh_token,
|
|
mastodon_account=user_data,
|
|
)
|
|
|
|
|
|
def register_new_user(request, **param):
|
|
if settings.INVITE_ONLY:
|
|
if not Takahe.verify_invite(request.session.get("invite")):
|
|
return render(
|
|
request,
|
|
"common/error.html",
|
|
{
|
|
"msg": _("Authentication failed"),
|
|
"secondary_msg": _("Registration is for invitation only"),
|
|
},
|
|
)
|
|
else:
|
|
del request.session["invite"]
|
|
new_user = User.register(**param)
|
|
request.session["new_user"] = True
|
|
auth_login(request, new_user)
|
|
response = redirect(reverse("users:register"))
|
|
response.delete_cookie(settings.TAKAHE_SESSION_COOKIE_NAME)
|
|
return response
|
|
|
|
|
|
def login_existing_user(request, existing_user):
|
|
auth_login(request, existing_user)
|
|
if not existing_user.username or not existing_user.identity:
|
|
response = redirect(reverse("users:register"))
|
|
elif request.session.get("next_url") is not None:
|
|
response = redirect(request.session.get("next_url"))
|
|
del request.session["next_url"]
|
|
else:
|
|
response = redirect(reverse("common:home"))
|
|
response.delete_cookie(settings.TAKAHE_SESSION_COOKIE_NAME)
|
|
return response
|
|
|
|
|
|
@mastodon_request_included
|
|
@login_required
|
|
def logout(request):
|
|
# revoke_token(request.user.mastodon_site, request.user.mastodon_token)
|
|
return auth_logout(request)
|
|
|
|
|
|
@mastodon_request_included
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def reconnect(request):
|
|
if request.META.get("HTTP_AUTHORIZATION"):
|
|
raise BadRequest("Only for web login")
|
|
request.session["swap_login"] = True
|
|
request.session["swap_domain"] = request.POST["domain"]
|
|
return connect(request)
|
|
|
|
|
|
class RegistrationForm(forms.ModelForm):
|
|
email = forms.EmailField(required=False)
|
|
|
|
class Meta:
|
|
model = User
|
|
fields = ["username"]
|
|
|
|
def clean_username(self):
|
|
username = self.cleaned_data.get("username")
|
|
if username and self.instance and self.instance.username:
|
|
username = self.instance.username
|
|
elif (
|
|
username
|
|
and User.objects.filter(username__iexact=username)
|
|
.exclude(pk=self.instance.pk if self.instance else -1)
|
|
.exists()
|
|
):
|
|
raise forms.ValidationError(_("This username is already in use."))
|
|
return username
|
|
|
|
def clean_email(self):
|
|
email = self.cleaned_data.get("email")
|
|
if (
|
|
email
|
|
and User.objects.filter(email__iexact=email)
|
|
.exclude(pk=self.instance.pk if self.instance else -1)
|
|
.exists()
|
|
):
|
|
raise forms.ValidationError(_("This email address is already in use."))
|
|
return email
|
|
|
|
|
|
def send_verification_link(user_id, action, email, code=""):
|
|
s = {"i": user_id, "e": email, "a": action}
|
|
v = TimestampSigner().sign_object(s)
|
|
footer = _(
|
|
"\n\nIf you did not mean to register or login, please ignore this email. If you are concerned with your account security, please change the email linked with your account, or contact us."
|
|
)
|
|
site = settings.SITE_INFO["site_name"]
|
|
if action == "verify":
|
|
subject = f'{site} - {_("Verification")}'
|
|
url = settings.SITE_INFO["site_url"] + "/account/verify_email?c=" + v
|
|
msg = _("Click this link to verify your email address {email}\n{url}").format(
|
|
email=email, url=url, code=code
|
|
)
|
|
msg += footer
|
|
elif action == "login":
|
|
subject = f'{site} - {_("Login")} {code}'
|
|
url = settings.SITE_INFO["site_url"] + "/account/login/email?c=" + v
|
|
msg = _(
|
|
"Use this code to confirm login as {email}\n\n{code}\n\nOr click this link to login\n{url}"
|
|
).format(email=email, url=url, code=code)
|
|
msg += footer
|
|
elif action == "register":
|
|
subject = f'{site} - {_("Register")}'
|
|
url = settings.SITE_INFO["site_url"] + "/account/register_email?c=" + v
|
|
msg = _(
|
|
"There is no account registered with this email address yet.{email}\n\nIf you already have an account with a Fediverse identity, just login and add this email to you account.\n\n"
|
|
).format(email=email, url=url, code=code)
|
|
if settings.ALLOW_EMAIL_ONLY_ACCOUNT:
|
|
msg += _(
|
|
"\nIf you prefer to register a new account, please use this code: {code}\nOr click this link:\n{url}"
|
|
).format(email=email, url=url, code=code)
|
|
msg += footer
|
|
else:
|
|
raise ValueError("Invalid action")
|
|
try:
|
|
logger.info(f"Sending email to {email} with subject {subject}")
|
|
logger.debug(msg)
|
|
send_mail(
|
|
subject=subject,
|
|
message=msg,
|
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
|
recipient_list=[email],
|
|
fail_silently=False,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"send email {email} failed", extra={"exception": e})
|
|
|
|
|
|
@require_http_methods(["POST"])
|
|
def verify_code(request):
|
|
code = request.POST.get("code")
|
|
if not code:
|
|
return render(
|
|
request,
|
|
"common/verify.html",
|
|
{
|
|
"error": _("Invalid verification code"),
|
|
},
|
|
)
|
|
login_email = cache.get(f"login_{code}")
|
|
if not login_email or request.session.get("login_email") != login_email:
|
|
return render(
|
|
request,
|
|
"common/verify.html",
|
|
{
|
|
"error": _("Invalid verification code"),
|
|
},
|
|
)
|
|
cache.delete(f"login_{code}")
|
|
user = User.objects.filter(email__iexact=login_email).first()
|
|
if user:
|
|
resp = login_existing_user(request, user)
|
|
else:
|
|
resp = register_new_user(request, username=None, email=login_email)
|
|
resp.set_cookie("mastodon_domain", "@")
|
|
return resp
|
|
|
|
|
|
def verify_email(request):
|
|
error = ""
|
|
try:
|
|
s = TimestampSigner().unsign_object(request.GET.get("c"), max_age=60 * 15)
|
|
except Exception as e:
|
|
logger.warning(f"login link invalid {e}")
|
|
error = _("Invalid verification link")
|
|
return render(
|
|
request, "users/verify_email.html", {"success": False, "error": error}
|
|
)
|
|
try:
|
|
email = s["e"]
|
|
action = s["a"]
|
|
if action == "verify":
|
|
user = User.objects.get(pk=s["i"])
|
|
if user.pending_email == email:
|
|
user.email = user.pending_email
|
|
user.pending_email = None
|
|
user.save(update_fields=["email", "pending_email"])
|
|
return render(
|
|
request, "users/verify_email.html", {"success": True, "user": user}
|
|
)
|
|
else:
|
|
error = _("Email mismatch")
|
|
elif action == "login":
|
|
user = User.objects.get(pk=s["i"])
|
|
if user.email == email:
|
|
return login_existing_user(request, user)
|
|
else:
|
|
error = _("Email mismatch")
|
|
elif action == "register":
|
|
user = User.objects.filter(email__iexact=email).first()
|
|
if user:
|
|
error = _("Email in use")
|
|
else:
|
|
return register_new_user(request, username=None, email=email)
|
|
except Exception as e:
|
|
logger.error("verify email error", extra={"exception": e, "s": s})
|
|
error = _("Unable to verify")
|
|
return render(
|
|
request, "users/verify_email.html", {"success": False, "error": error}
|
|
)
|
|
|
|
|
|
@login_required
|
|
def register(request: AuthedHttpRequest):
|
|
form = None
|
|
if settings.MASTODON_ALLOW_ANY_SITE:
|
|
form = RegistrationForm(request.POST)
|
|
form.instance = (
|
|
User.objects.get(pk=request.user.pk)
|
|
if request.user.is_authenticated
|
|
else None
|
|
)
|
|
if request.method == "GET" or not form:
|
|
return render(request, "users/register.html", {"form": form})
|
|
elif request.method == "POST":
|
|
username_changed = False
|
|
email_cleared = False
|
|
if not form.is_valid():
|
|
return render(request, "users/register.html", {"form": form})
|
|
if not request.user.username and form.cleaned_data["username"]:
|
|
if User.objects.filter(
|
|
username__iexact=form.cleaned_data["username"]
|
|
).exists():
|
|
return render(
|
|
request,
|
|
"users/register.html",
|
|
{
|
|
"form": form,
|
|
"error": _("Username in use"),
|
|
},
|
|
)
|
|
request.user.username = form.cleaned_data["username"]
|
|
username_changed = True
|
|
if form.cleaned_data["email"]:
|
|
if form.cleaned_data["email"].lower() != (request.user.email or "").lower():
|
|
if User.objects.filter(
|
|
email__iexact=form.cleaned_data["email"]
|
|
).exists():
|
|
return render(
|
|
request,
|
|
"users/register.html",
|
|
{
|
|
"form": form,
|
|
"error": _("Email in use"),
|
|
},
|
|
)
|
|
request.user.pending_email = form.cleaned_data["email"]
|
|
else:
|
|
request.user.pending_email = None
|
|
elif request.user.email or request.user.pending_email:
|
|
request.user.pending_email = None
|
|
request.user.email = None
|
|
email_cleared = True
|
|
request.user.save()
|
|
if request.user.pending_email:
|
|
django_rq.get_queue("mastodon").enqueue(
|
|
send_verification_link,
|
|
request.user.pk,
|
|
"verify",
|
|
request.user.pending_email,
|
|
)
|
|
messages.add_message(
|
|
request,
|
|
messages.INFO,
|
|
_("Verification email is being sent, please check your inbox."),
|
|
)
|
|
if request.user.username and not request.user.identity_linked():
|
|
request.user.initialize()
|
|
if username_changed:
|
|
messages.add_message(request, messages.INFO, _("Username all set."))
|
|
if email_cleared:
|
|
messages.add_message(
|
|
request, messages.INFO, _("Email removed from account.")
|
|
)
|
|
if request.session.get("new_user"):
|
|
del request.session["new_user"]
|
|
return redirect(request.GET.get("next", reverse("common:home")))
|
|
|
|
|
|
def swap_login(request, token, site, refresh_token):
|
|
del request.session["swap_login"]
|
|
del request.session["swap_domain"]
|
|
code, data = verify_account(site, token)
|
|
current_user = request.user
|
|
if code == 200 and data is not None:
|
|
username = data["username"]
|
|
if (
|
|
username == current_user.mastodon_username
|
|
and site == current_user.mastodon_site
|
|
):
|
|
messages.add_message(
|
|
request,
|
|
messages.ERROR,
|
|
_("Unable to update login information: identical identity."),
|
|
)
|
|
else:
|
|
try:
|
|
User.objects.get(
|
|
mastodon_username__iexact=username, mastodon_site__iexact=site
|
|
)
|
|
messages.add_message(
|
|
request,
|
|
messages.ERROR,
|
|
_("Unable to update login information: identity in use."),
|
|
)
|
|
except ObjectDoesNotExist:
|
|
current_user.mastodon_username = username
|
|
current_user.mastodon_id = data["id"]
|
|
current_user.mastodon_site = site
|
|
current_user.mastodon_token = token
|
|
current_user.mastodon_refresh_token = refresh_token
|
|
current_user.mastodon_account = data
|
|
current_user.save(
|
|
update_fields=[
|
|
"username",
|
|
"mastodon_id",
|
|
"mastodon_username",
|
|
"mastodon_site",
|
|
"mastodon_token",
|
|
"mastodon_refresh_token",
|
|
"mastodon_account",
|
|
]
|
|
)
|
|
django_rq.get_queue("mastodon").enqueue(
|
|
refresh_mastodon_data_task, current_user.pk, token
|
|
)
|
|
messages.add_message(
|
|
request,
|
|
messages.INFO,
|
|
_("Login information updated.") + f" {username}@{site}",
|
|
)
|
|
else:
|
|
messages.add_message(
|
|
request, messages.ERROR, _("Invalid account data from Fediverse instance.")
|
|
)
|
|
return redirect(reverse("users:data"))
|
|
|
|
|
|
def clear_preference_cache(request):
|
|
for key in list(request.session.keys()):
|
|
if key.startswith("p_"):
|
|
del request.session[key]
|
|
|
|
|
|
def auth_login(request, user):
|
|
"""Decorates django ``login()``. Attach token to session."""
|
|
auth.login(request, user, backend="mastodon.auth.OAuth2Backend")
|
|
clear_preference_cache(request)
|
|
if (
|
|
user.mastodon_last_refresh < timezone.now() - timedelta(hours=1)
|
|
or user.mastodon_account == {}
|
|
):
|
|
django_rq.get_queue("mastodon").enqueue(refresh_mastodon_data_task, user.pk)
|
|
|
|
|
|
def auth_logout(request):
|
|
"""Decorates django ``logout()``. Release token in session."""
|
|
auth.logout(request)
|
|
response = redirect("/")
|
|
response.delete_cookie(settings.TAKAHE_SESSION_COOKIE_NAME)
|
|
return response
|
|
|
|
|
|
def clear_data_task(user_id):
|
|
user = User.objects.get(pk=user_id)
|
|
user_str = str(user)
|
|
if user.identity:
|
|
remove_data_by_user(user.identity)
|
|
Takahe.delete_identity(user.identity.pk)
|
|
user.clear()
|
|
logger.warning(f"User {user_str} data cleared.")
|
|
|
|
|
|
@login_required
|
|
def clear_data(request):
|
|
if request.META.get("HTTP_AUTHORIZATION"):
|
|
raise BadRequest("Only for web login")
|
|
if request.method == "POST":
|
|
v = request.POST.get("verification")
|
|
if v and (v == request.user.mastodon_acct or v == request.user.email):
|
|
django_rq.get_queue("mastodon").enqueue(clear_data_task, request.user.id)
|
|
return auth_logout(request)
|
|
else:
|
|
messages.add_message(request, messages.ERROR, _("Account mismatch."))
|
|
return redirect(reverse("users:data"))
|