Compare commits

..

6 Commits

Author SHA1 Message Date
dependabot[bot]
0b93491b32
Merge 43de2c9f5d into d8c4c0a927 2023-12-28 22:00:28 +00:00
dependabot[bot]
43de2c9f5d
Bump fastapi from 0.104.1 to 0.108.0
Bumps [fastapi](https://github.com/tiangolo/fastapi) from 0.104.1 to 0.108.0.
- [Release notes](https://github.com/tiangolo/fastapi/releases)
- [Commits](https://github.com/tiangolo/fastapi/compare/0.104.1...0.108.0)

---
updated-dependencies:
- dependency-name: fastapi
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-28 22:00:25 +00:00
d8c4c0a927 fixed 2fa for non 2fa 2023-12-29 00:56:44 +03:00
bf9e2beda1 added user option second factor totp 2023-12-29 00:24:01 +03:00
3adeb7c5a7 added manage command 2023-12-28 02:28:47 +03:00
af5f1f8afc added music search, major improvements on file search 2023-12-28 02:15:50 +03:00
21 changed files with 471 additions and 34 deletions

View File

@ -1,4 +1,4 @@
from django_elasticsearch_dsl import Document
from django_elasticsearch_dsl import Document, fields
from django_elasticsearch_dsl.registries import registry
from akarpov.files.models import File
@ -6,26 +6,41 @@
@registry.register_document
class FileDocument(Document):
class Index:
name = "files"
settings = {"number_of_shards": 1, "number_of_replicas": 0}
name = fields.TextField(
attr="name",
fields={
"raw": fields.KeywordField(normalizer="lowercase"),
},
)
description = fields.TextField(
attr="description",
fields={
"raw": fields.KeywordField(normalizer="lowercase"),
},
)
content = fields.TextField(
attr="content",
fields={
"raw": fields.KeywordField(normalizer="lowercase"),
},
)
class Django:
model = File
fields = [
"name",
"description",
"content",
]
def prepare_description(self, instance):
# This method is called for every instance before indexing
return instance.description or ""
def prepare_content(self, instance):
# This method is called for every instance before indexing
# check instance.content is not None
return (
instance.content.decode("utf-8")
if isinstance(instance.content, bytes)
else instance.content
if instance.content and isinstance(instance.content, bytes)
else ""
)
class Index:
name = "files"
settings = {"number_of_shards": 1, "number_of_replicas": 0}

View File

@ -40,13 +40,16 @@ def search(self, query: str):
ES_Q(
"multi_match",
query=query,
fields=["name", "description", "content"],
fields=["name^3", "description^2", "content"],
type="best_fields",
fuzziness="AUTO",
),
ES_Q("match_phrase_prefix", name=query),
ES_Q("wildcard", name=f"*{query}*"),
ES_Q("wildcard", description=f"*{query}*"),
ES_Q("wildcard", content=f"*{query}*"),
ES_Q("wildcard", name__raw=f"*{query.lower()}*"),
ES_Q("wildcard", description__raw=f"*{query.lower()}*"),
ES_Q("wildcard", content__raw=f"*{query.lower()}*"),
ES_Q("wildcard", file_type__raw=f"*{query.lower()}*"),
ES_Q("wildcard", file_obj__raw=f"*{query.lower()}*"),
ES_Q("wildcard", preview__raw=f"*{query.lower()}*"),
],
minimum_should_match=1,
)

View File

@ -25,6 +25,7 @@
SongUserRating,
UserListenHistory,
)
from akarpov.music.services.search import search_song
from akarpov.music.tasks import listen_to_song
@ -83,6 +84,10 @@ class ListCreateSongAPIView(LikedSongsContextMixin, generics.ListCreateAPIView):
pagination_class = StandardResultsSetPagination
def get_queryset(self):
search = self.request.query_params.get("search", None)
if search:
qs = search_song(search)
else:
qs = Song.objects.cache()
if "sort" in self.request.query_params:
@ -111,6 +116,12 @@ def get_queryset(self):
@extend_schema(
parameters=[
OpenApiParameter(
name="search",
description="Search query",
required=False,
type=str,
),
OpenApiParameter(
name="sort",
description="Sorting algorithm",

View File

@ -0,0 +1,67 @@
from django_elasticsearch_dsl import Document, fields
from django_elasticsearch_dsl.registries import registry
from akarpov.music.models import Song
@registry.register_document
class SongDocument(Document):
authors = fields.NestedField(
attr="authors",
properties={
"name": fields.TextField(
fields={
"raw": fields.KeywordField(normalizer="lowercase"),
},
),
"link": fields.TextField(),
"meta": fields.ObjectField(dynamic=True),
},
)
album = fields.NestedField(
attr="album",
properties={
"name": fields.TextField(
fields={
"raw": fields.KeywordField(normalizer="lowercase"),
},
),
"link": fields.TextField(),
"meta": fields.ObjectField(dynamic=True),
},
)
name = fields.TextField(
attr="name",
fields={
"raw": fields.KeywordField(normalizer="lowercase"),
},
)
meta = fields.ObjectField(dynamic=True) # Added meta field here as dynamic object
class Index:
name = "songs"
settings = {"number_of_shards": 1, "number_of_replicas": 0}
# settings = {
# "number_of_shards": 1,
# "number_of_replicas": 0,
# "analysis": {
# "analyzer": {
# "russian_icu": {
# "type": "custom",
# "tokenizer": "icu_tokenizer",
# "filter": ["icu_folding","icu_normalizer"]
# }
# }
# }
# } TODO
class Django:
model = Song
def get_instances_from_related(self, related_instance):
if isinstance(related_instance, Song):
return related_instance.album
return related_instance.songs.all()

View File

@ -19,6 +19,7 @@ def load_dir(path: str, user_id: int):
def load_file(path: str, user_id: int):
# TODO: convert to mp3 if not mp3
process_mp3_file(path, user_id)

View File

@ -0,0 +1,47 @@
from django.db.models import Case, When
from elasticsearch_dsl import Q as ES_Q
from akarpov.music.documents import SongDocument
from akarpov.music.models import Song
def search_song(query):
search = SongDocument.search()
search_query = ES_Q(
"bool",
should=[
ES_Q(
"multi_match",
query=query,
fields=["name^3", "authors.name^2", "album.name"],
fuzziness="AUTO",
), # Change here
ES_Q("wildcard", name__raw=f"*{query.lower()}*"),
ES_Q(
"nested",
path="authors",
query=ES_Q("wildcard", authors__name__raw=f"*{query.lower()}*"),
),
ES_Q(
"nested",
path="album",
query=ES_Q("wildcard", album__name__raw=f"*{query.lower()}*"),
),
],
minimum_should_match=1,
)
search = search.query(search_query)
response = search.execute()
# Check for hits and get song instances
if response.hits:
hit_ids = [hit.meta.id for hit in response.hits]
songs = Song.objects.filter(id__in=hit_ids).order_by(
Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(hit_ids)])
)
return songs
return Song.objects.none()

View File

@ -93,8 +93,9 @@
<ul class="dropdown-menu dropdown-menu-dark text-small shadow" aria-labelledby="dropdownUser1">
<li><a class="dropdown-item {% active_link 'users:update' %}" href="{% url 'users:update' %}">Settings</a></li>
<li><a class="dropdown-item {% active_link 'users:detail' request.user.username %}" href="{% url 'users:detail' request.user.username %}">Profile</a></li>
<li><a class="dropdown-item {% active_link 'tools:qr:create' %}" href="{% url 'tools:promocodes:activate' %}">Activate promocode</a></li>
<li><a class="dropdown-item {% active_link 'tools:qr:create' %}" href="{% url 'users:history' %}">History</a></li>
<li><a class="dropdown-item {% active_link 'tools:promocodes:activate' %}" href="{% url 'tools:promocodes:activate' %}">Activate promocode</a></li>
<li><a class="dropdown-item {% active_link 'users:history' %}" href="{% url 'users:history' %}">History</a></li>
<li><a class="dropdown-item {% active_link 'users:enable_2fa' %}" href="{% url 'users:enable_2fa' %}">2FA</a></li>
<li>
<hr class="dropdown-divider">
</li>

View File

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-md-6 mx-auto">
<div class="card mt-5">
<div class="card-body">
<h2 class="text-center">Disable Two-Factor Authentication</h2>
<p>If you want to disable two-factor authentication, please enter a token generated by your authentication app below. This is to verify your identity and to prevent unauthorized individuals from disabling your two-factor authentication.</p>
<p>If faced problems with disabling contact support</p>
<br>
<form method="POST">
{% csrf_token %}
{% crispy form %}
<button type="submit" class="btn btn-primary btn-block" value="Disable 2FA">
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container">
<h1 class="mt-5">Enable Two-Factor Authentication</h1>
<p class="lead">Scan the QR code below or enter key with your TOTP app, then enter the code provided by the app to enable 2FA.</p>
<p>Your TOTP secret key is: {{ totp_key }}</p>
<div class="mb-4">
<img src="data:image/svg+xml;utf8,{{ qr_code_svg }}">
</div>
<form method="post" class="form">
{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="btn btn-primary mt-2">Confirm</button>
<button type="submit" class="btn btn-secondary mt-2" name="cancel">Cancel</button>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header bg-primary text-white">Enter OTP</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="btn btn-primary mt-2">Submit OTP</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,5 +1,6 @@
from allauth.account.forms import SignupForm
from allauth.socialaccount.forms import SignupForm as SocialSignupForm
from django import forms
from django.contrib.auth import forms as admin_forms
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
@ -40,3 +41,7 @@ class UserSocialSignupForm(SocialSignupForm):
Default fields will be added automatically.
See UserSignupForm otherwise.
"""
class OTPForm(forms.Form):
otp_token = forms.CharField()

View File

@ -1,4 +1,8 @@
from cacheops import cached_as
from django.shortcuts import redirect
from django.urls import resolve
from django.utils.deprecation import MiddlewareMixin
from django_otp.plugins.otp_totp.models import TOTPDevice
from rest_framework.exceptions import AuthenticationFailed
@ -8,3 +12,35 @@ def process_request(self, request):
if not request.user.is_verified:
raise AuthenticationFailed("Email is not verified")
return None
class Enforce2FAMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
# Check user is authenticated and OTP token input is not completed
is_authenticated = request.user.is_authenticated
otp_not_verified = not request.session.get("otp_verified", False)
on_2fa_page = resolve(request.path_info).url_name == "enforce_otp_login"
# Caches the checker for has_otp_device
@cached_as(
TOTPDevice, timeout=15 * 60
) # consider appropriate time for your use case
def has_otp_device(user):
return TOTPDevice.objects.devices_for_user(user, confirmed=True).exists()
# Enforce OTP token input, if user is authenticated, has OTP enabled but has not verified OTP
if (
is_authenticated
and has_otp_device(request.user)
and otp_not_verified
and not on_2fa_page
):
request.session["next"] = request.get_full_path()
return redirect("users:enforce_otp_login")
return response

View File

@ -0,0 +1,14 @@
import io
import qrcode
import qrcode.image.svg
def generate_qr_code(data):
qr_image = qrcode.make(data, image_factory=qrcode.image.svg.SvgImage)
byte_arr = io.BytesIO()
qr_image.save(byte_arr)
qr_svg = byte_arr.getvalue().decode().replace("\n", "")
return qr_svg

View File

@ -1,6 +1,8 @@
from django.urls import include, path
from akarpov.users.views import (
enable_2fa_view,
enforce_otp_login,
user_detail_view,
user_history_delete_view,
user_history_view,
@ -16,4 +18,6 @@
path("history/", view=user_history_view, name="history"),
path("history/delete", view=user_history_delete_view, name="history_delete"),
path("<str:username>/", view=user_detail_view, name="detail"),
path("2fa/login", enforce_otp_login, name="enforce_otp_login"),
path("2fa/enable", enable_2fa_view, name="enable_2fa"),
]

View File

@ -1,12 +1,22 @@
from allauth.account.views import LoginView
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse
from django.contrib.sites.shortcuts import get_current_site
from django.http import HttpResponseRedirect
from django.shortcuts import redirect, render
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, RedirectView, UpdateView
from django_otp import user_has_device
from django_otp.plugins.otp_totp.models import TOTPDevice
from akarpov.users.forms import OTPForm
from akarpov.users.models import UserHistory
from akarpov.users.services.history import create_history_warning_note
from akarpov.users.services.two_factor import generate_qr_code
from akarpov.users.themes.models import Theme
User = get_user_model()
@ -87,3 +97,103 @@ def get_redirect_url(self):
user_history_delete_view = UserHistoryDeleteView.as_view()
@login_required
def enable_2fa_view(request):
user = request.user
devices = TOTPDevice.objects.filter(user=user, confirmed=True)
if devices.exists():
if request.method == "POST":
form = OTPForm(request.POST)
if form.is_valid():
token = form.cleaned_data["otp_token"]
# Verifying the token against all confirmed devices
for device in devices:
if device.verify_token(token):
device.delete() # Delete the device if the token is valid
# Check if there are still confirmed devices left
if not TOTPDevice.objects.filter(user=user, confirmed=True).exists():
messages.success(request, "Two-factor authentication disabled!")
return redirect(reverse_lazy("blog:post_list"))
else:
messages.error(request, "Invalid token, please try again.")
else:
messages.error(
request, "The form submission was not valid. Please, try again."
)
form = OTPForm(initial={"otp_token": ""})
return render(request, "users/disable_2fa.html", {"form": form})
else:
if request.method == "POST":
device = TOTPDevice.objects.filter(user=user, confirmed=False).first()
if request.POST.get("cancel"):
device.delete()
return redirect(reverse_lazy("blog:post_list"))
form = OTPForm(request.POST)
if form.is_valid():
token = form.cleaned_data["otp_token"]
if device and device.verify_token(token):
device.confirmed = True
device.save()
messages.success(request, "Two-factor authentication enabled!")
return redirect(reverse_lazy("blog:post_list"))
else:
messages.error(request, "Invalid token, please try again.")
else:
device, created = TOTPDevice.objects.get_or_create(
user=user, confirmed=False
)
site_name = get_current_site(request).name
provisioning_url = device.config_url
provisioning_url_site = provisioning_url.replace(
"otpauth://totp/", f"otpauth://totp/{site_name}:"
)
qr_code_svg = generate_qr_code(provisioning_url_site)
totp_key = device.key # get device's secret key
form = OTPForm(initial={"otp_token": ""})
return render(
request,
"users/enable_2fa.html",
{"form": form, "qr_code_svg": qr_code_svg, "totp_key": totp_key},
)
class OTPLoginView(LoginView):
def form_valid(self, form):
# Store URL for next page from the Login form
self.request.session["next"] = self.request.GET.get("next")
resp = super().form_valid(form)
# Redirect users with OTP devices to enter their tokens
if user_has_device(self.request.user):
return HttpResponseRedirect(reverse("users:enforce_otp_login"))
return resp
login_view = OTPLoginView.as_view()
@login_required
def enforce_otp_login(request):
# TODO gather next url from loginrequired
if request.method == "POST":
form = OTPForm(request.POST)
if form.is_valid():
otp_token = form.cleaned_data["otp_token"]
device = TOTPDevice.objects.filter(user=request.user).first()
if device.verify_token(otp_token):
request.session["otp_verified"] = True
success_url = request.session.pop("next", None) or reverse_lazy("home")
return HttpResponseRedirect(success_url)
else:
form = OTPForm()
return render(request, "users/otp_verify.html", {"form": form})

View File

@ -48,6 +48,14 @@ COPY ./compose/production/django/entrypoint /entrypoint
RUN sed -i 's/\r$//g' /entrypoint
RUN chmod +x /entrypoint
COPY ./compose/production/django/manage /manage
RUN sed -i 's/\r$//g' /manage
RUN chmod +x /manage
COPY ./compose/production/django/manage /manage.py
RUN sed -i 's/\r$//g' /manage
RUN chmod +x /manage
COPY ./compose/local/django/start /start
RUN sed -i 's/\r$//g' /start
RUN chmod +x /start

View File

@ -0,0 +1,14 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
if [ -z "${POSTGRES_USER}" ]; then
base_postgres_image_default_user='postgres'
export POSTGRES_USER="${base_postgres_image_default_user}"
fi
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
exec /venv/bin/python /app/manage.py "$@"

View File

@ -77,6 +77,7 @@
"files.*": {"ops": ("fetch", "get", "list"), "timeout": 60},
"auth.permission": {"ops": "all", "timeout": 60 * 15},
"music.*": {"ops": ("fetch", "get", "list"), "timeout": 60 * 15},
"otp_totp.totpdevice": {"ops": "all", "timeout": 15 * 60},
}
CACHEOPS_REDIS = env.str("REDIS_URL")
@ -143,6 +144,12 @@
"django_tables2",
"location_field",
"django_elasticsearch_dsl",
# 2fa
"django_otp",
"django_otp.plugins.otp_static",
"django_otp.plugins.otp_totp",
"django_otp.plugins.otp_hotp",
"django_otp.plugins.otp_email",
]
HEALTH_CHECKS = [
@ -237,6 +244,8 @@
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django_otp.middleware.OTPMiddleware",
"akarpov.users.middleware.Enforce2FAMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.common.BrokenLinkEmailsMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
@ -317,17 +326,6 @@
# EMAIL
# ------------------------------------------------------------------------------
"""
host: EMAIL_HOST
port: EMAIL_PORT
username: EMAIL_HOST_USER
password: EMAIL_HOST_PASSWORD
use_tls: EMAIL_USE_TLS
use_ssl: EMAIL_USE_SSL
timeout: EMAIL_TIMEOUT
ssl_keyfile: EMAIL_SSL_KEYFILE
ssl_certfile: EMAIL_SSL_CERTFILE
"""
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
EMAIL_BACKEND = env(
"DJANGO_EMAIL_BACKEND",

View File

@ -14,6 +14,7 @@
from akarpov.about.views import about_view, list_faq
from akarpov.tools.shortener.views import redirect_view
from akarpov.users.views import OTPLoginView
from config.sitemaps import sitemaps
urlpatterns = [
@ -44,6 +45,7 @@
path("gallery/", include("akarpov.gallery.urls", namespace="gallery")),
path("ckeditor/", include("ckeditor_uploader.urls")),
path("accounts/", include("allauth.urls")),
path("accounts/login/", OTPLoginView.as_view(), name="account_login"),
path("", include("akarpov.blog.urls", namespace="blog")),
path("<str:slug>", redirect_view, name="short_url"),
# Your stuff: custom urls includes go here

37
poetry.lock generated
View File

@ -4663,6 +4663,17 @@ files = [
{file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
]
[[package]]
name = "pypng"
version = "0.20220715.0"
description = "Pure Python library for saving and loading PNG images"
optional = false
python-versions = "*"
files = [
{file = "pypng-0.20220715.0-py3-none-any.whl", hash = "sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c"},
{file = "pypng-0.20220715.0.tar.gz", hash = "sha256:739c433ba96f078315de54c0db975aee537cbc3e1d0ae4ed9aab0ca1e427e2c1"},
]
[[package]]
name = "pysocks"
version = "1.7.1"
@ -5024,6 +5035,30 @@ files = [
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
]
[[package]]
name = "qrcode"
version = "7.4.2"
description = "QR Code image generator"
optional = false
python-versions = ">=3.7"
files = [
{file = "qrcode-7.4.2-py3-none-any.whl", hash = "sha256:581dca7a029bcb2deef5d01068e39093e80ef00b4a61098a2182eac59d01643a"},
{file = "qrcode-7.4.2.tar.gz", hash = "sha256:9dd969454827e127dbd93696b20747239e6d540e082937c90f14ac95b30f5845"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
pillow = {version = ">=9.1.0", optional = true, markers = "extra == \"pil\""}
pypng = "*"
typing-extensions = "*"
[package.extras]
all = ["pillow (>=9.1.0)", "pytest", "pytest-cov", "tox", "zest.releaser[recommended]"]
dev = ["pytest", "pytest-cov", "tox"]
maintainer = ["zest.releaser[recommended]"]
pil = ["pillow (>=9.1.0)"]
test = ["coverage", "pytest"]
[[package]]
name = "rawpy"
version = "0.19.0"
@ -6857,4 +6892,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "cca5127e57dc42315af8f1c481df518979c1ae77035c23283ead77adbc949142"
content-hash = "89fb842e968cf6d70a55ead6b5dc8ee0eddcbefd2f4d8685439799dceebef999"

View File

@ -115,6 +115,7 @@ numpy = "1.25.2"
deep-translator = "1.4.2"
textract = {git = "https://github.com/Alexander-D-Karpov/textract.git", branch = "master"}
django-otp = "^1.3.0"
qrcode = {extras = ["pil"], version = "^7.4.2"}
[build-system]