diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..e377592 --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 4.0.3 on 2022-06-10 16:09 + +import accounts.utils +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="AccountConfirmChallenge", + fields=[ + ( + "challenge_text", + models.CharField( + blank=True, + default=accounts.utils.gen_secret, + editable=False, + max_length=32, + primary_key=True, + serialize=False, + unique=True, + verbose_name="Challenge text", + ), + ), + ( + "owned_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/accounts/migrations/0002_alter_accountconfirmchallenge_owned_by.py b/accounts/migrations/0002_alter_accountconfirmchallenge_owned_by.py new file mode 100644 index 0000000..f29b053 --- /dev/null +++ b/accounts/migrations/0002_alter_accountconfirmchallenge_owned_by.py @@ -0,0 +1,25 @@ +# Generated by Django 4.0.3 on 2022-06-10 16:28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("accounts", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="accountconfirmchallenge", + name="owned_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + unique=True, + ), + ), + ] diff --git a/accounts/migrations/0003_alter_accountconfirmchallenge_owned_by.py b/accounts/migrations/0003_alter_accountconfirmchallenge_owned_by.py new file mode 100644 index 0000000..8e01ce3 --- /dev/null +++ b/accounts/migrations/0003_alter_accountconfirmchallenge_owned_by.py @@ -0,0 +1,23 @@ +# Generated by Django 4.0.3 on 2022-06-10 16:28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("accounts", "0002_alter_accountconfirmchallenge_owned_by"), + ] + + operations = [ + migrations.AlterField( + model_name="accountconfirmchallenge", + name="owned_by", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/accounts/migrations/0004_accountconfirmchallenge_public_ref_and_more.py b/accounts/migrations/0004_accountconfirmchallenge_public_ref_and_more.py new file mode 100644 index 0000000..2f52655 --- /dev/null +++ b/accounts/migrations/0004_accountconfirmchallenge_public_ref_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.0.3 on 2022-06-10 16:44 + +import accounts.utils +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0003_alter_accountconfirmchallenge_owned_by"), + ] + + operations = [ + migrations.AddField( + model_name="accountconfirmchallenge", + name="public_ref", + field=models.CharField( + default=accounts.utils.gen_secret, + editable=False, + max_length=32, + unique=True, + verbose_name="Public referece to challenge text", + ), + ), + migrations.AlterField( + model_name="accountconfirmchallenge", + name="challenge_text", + field=models.CharField( + default=accounts.utils.gen_secret, + editable=False, + max_length=32, + primary_key=True, + serialize=False, + unique=True, + verbose_name="Challenge text", + ), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 71a8362..5701990 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,3 +1,50 @@ -from django.db import models +# Copyright © 2022 Aravinth Manivannan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . -# Create your models here. +from django.db import models +from django.contrib.auth.models import User +from django.utils.http import urlencode +from django.urls import reverse + +from .utils import gen_secret + + +class AccountConfirmChallenge(models.Model): + owned_by = models.OneToOneField(User, on_delete=models.CASCADE) + public_ref = models.CharField( + "Public referece to challenge text", + unique=True, + max_length=32, + default=gen_secret, + editable=False, + ) + + challenge_text = models.CharField( + "Challenge text", + unique=True, + max_length=32, + default=gen_secret, + editable=False, + primary_key=True, + ) + + def __str__(self): + return f"{self.challenge_text}" + + def verification_link(self): + """ + Get verification link + """ + return reverse("accounts.verify", args=(self.challenge_text,)) diff --git a/accounts/templates/accounts/protected.html b/accounts/templates/accounts/protected.html index 36ff7c2..8c3c5f0 100644 --- a/accounts/templates/accounts/protected.html +++ b/accounts/templates/accounts/protected.html @@ -9,7 +9,7 @@ diff --git a/accounts/urls.py b/accounts/urls.py index 75d4caf..5070368 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -15,11 +15,32 @@ from django.contrib import admin from django.urls import path, include -from .views import login_view, public_view, logout_view, protected_view +from .views import ( + login_view, + logout_view, + protected_view, + register_view, + default_login_url, + verify_account, + resend_verification_email_view, + verification_pending_view, +) urlpatterns = [ path("login/", login_view, name="accounts.login"), + path("register/", register_view, name="accounts.register"), path("logout/", login_view, name="accounts.logout"), - path("protected/", protected_view, name="accounts.protected"), - path("", public_view, name="accounts.public"), + path("accounts/login/", default_login_url, name="accounts.default_login_url"), + path( + "accounts/verify/pending//", + verification_pending_view, + name="accounts.verify.pending", + ), + path( + "accounts/verify/resend//", + resend_verification_email_view, + name="accounts.verify.resend", + ), + path("accounts/verify//", verify_account, name="accounts.verify"), + path("", protected_view, name="accounts.home"), ] diff --git a/accounts/views.py b/accounts/views.py index 15165a6..9554ec0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -12,7 +12,8 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from django.shortcuts import render, redirect +from django.shortcuts import render, redirect, get_object_or_404 +from django.utils.http import urlencode from django.contrib.auth import authenticate, login, logout from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required @@ -21,6 +22,10 @@ from django.views.decorators.csrf import csrf_protect from django.urls import reverse +from .models import AccountConfirmChallenge +from .utils import send_verification_email + + @csrf_protect def login_view(request): if request.method == "GET": @@ -49,7 +54,7 @@ def login_view(request): next_url = request.POST["next"] if next_url: return redirect(next_url) - return redirect(reverse("accounts.protected")) + return redirect(reverse("accounts.home")) ctx = { "error": { @@ -65,11 +70,104 @@ def protected_view(request): return render(request, "accounts/protected.html") +def default_login_url(request): + ctx = {"next": request.GET["next"]} + return redirect(f"{reverse('accounts.login')}?{urlencode(ctx)}") + + @login_required def logout_view(request): logout(request) return redirect(reverse("accounts.login")) -def public_view(request): - return render(request, "accounts/public.html") +def register_view(request): + if request.method == "GET": + ctx = {} + if "next" in request.GET: + ctx["next"] = request.GET["next"] + return render(request, "accounts/auth/register.html", ctx) + + confirm_password = request.POST["confirm_password"] + password = request.POST["password"] + username = request.POST["username"] + email = request.POST["email"] + + if password != confirm_password: + ctx = { + "error": { + "title": "Registration Failed", + "reason": "Passwords don't match, please verify input", + }, + "usernme": username, + "email": email, + } + return render(request, "accounts/auth/register.html", status=400, context=ctx) + + User = get_user_model() + if User.objects.filter(email=email).exists(): + ctx = { + "error": { + "title": "Registration Failed", + "reason": "Email is already registered", + }, + "usernme": username, + "email": email, + } + return render(request, "accounts/auth/register.html", status=400, context=ctx) + + if User.objects.filter(username=username).exists(): + ctx = { + "error": { + "title": "Registration Failed", + "reason": "Username is already registered", + }, + "usernme": username, + "email": email, + } + return render(request, "accounts/auth/register.html", status=400, context=ctx) + + user = get_user_model().objects.create_user( + username=username, + email=email, + is_active=False, + password=password, + ) # TODO: get email from settings.py + + challenge = AccountConfirmChallenge(owned_by=user) + challenge.save() + send_verification_email(request, challenge=challenge) + + return redirect(reverse("accounts.verify.pending", args=(challenge.public_ref,))) + + +def verification_pending_view(request, public_ref): + challenge = get_object_or_404(AccountConfirmChallenge, public_ref=public_ref) + + ctx = { + "email": challenge.owned_by.email, + "public_ref": challenge.public_ref, + } + + return render(request, "accounts/auth/verification-pending.html", context=ctx) + + +def resend_verification_email_view(request, public_ref): + challenge = get_object_or_404(AccountConfirmChallenge, public_ref=public_ref) + send_verification_email(request, challenge=challenge) + return redirect(reverse("accounts.verify.pending", args=(challenge.public_ref,))) + + +def verify_account(request, challenge): + challenge = get_object_or_404(AccountConfirmChallenge, challenge_text=challenge) + + if request.method == "GET": + ctx = { + "challenge": challenge, + } + return render(request, "accounts/auth/verify.html", context=ctx) + + challenge.owned_by.is_active = True + challenge.owned_by.save() + challenge.delete() + return redirect("accounts.login") diff --git a/dashboard/settings.py b/dashboard/settings.py index 0ba0ff0..5a71070 100644 --- a/dashboard/settings.py +++ b/dashboard/settings.py @@ -115,7 +115,6 @@ USE_I18N = True USE_TZ = True - # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.0/howto/static-files/ @@ -130,10 +129,13 @@ STATICFILES_DIRS = [ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" - ### Dashbaord specific configuration options HOSTEA = { "RESTRICT_NEW_INTEGRATION_INSTALLATION": True, "INSTANCE_MAINTAINER_CONTACT": "contact@hostea.example.org", } + +EMAIL_CONFIG = env.email("EMAIL_URL", default="smtp://admin:password@localhost:10025") + +vars().update(EMAIL_CONFIG) diff --git a/static/css/main.css b/static/css/main.css index c29fc1f..d5914c4 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -386,6 +386,13 @@ pre { box-sizing: border-box; } +.dialogue-box__container { + width: 30%; + display: flex; + align-items: center; + flex-direction: column; +} + /* footer starts */ footer {