aboutsummaryrefslogtreecommitdiff
path: root/services
diff options
context:
space:
mode:
authorBobby <[email protected]>2024-12-15 19:11:48 -0500
committerBobby <[email protected]>2024-12-15 19:11:48 -0500
commit8bb529d5eefec11360e7aacb287120d6427793bd (patch)
tree356d0690288513eee99abedafd3cf0929a3f27a3 /services
parent12c92bf4d74f3717b1eafe1737e671a2b8bda02e (diff)
downloadthatcomputerscientist-8bb529d5eefec11360e7aacb287120d6427793bd.tar.xz
thatcomputerscientist-8bb529d5eefec11360e7aacb287120d6427793bd.zip
user stats
Diffstat (limited to 'services')
-rw-r--r--services/users/__init__.py0
-rw-r--r--services/users/accountFunctions.py59
-rw-r--r--services/users/admin.py7
-rw-r--r--services/users/apps.py6
-rw-r--r--services/users/forms.py279
-rw-r--r--services/users/mail_send.py75
-rw-r--r--services/users/migrations/0001_initial.py29
-rw-r--r--services/users/migrations/0002_userprofile_email_verified.py18
-rw-r--r--services/users/migrations/0003_captchastore.py22
-rw-r--r--services/users/migrations/0004_remove_captchastore_id_alter_captchastore_csrf_token.py22
-rw-r--r--services/users/migrations/0005_captchastore_id_alter_captchastore_csrf_token.py24
-rw-r--r--services/users/migrations/0006_delete_captchastore.py16
-rw-r--r--services/users/migrations/0007_captchastore.py22
-rw-r--r--services/users/migrations/0008_remove_userprofile_gravatar_email_and_more.py22
-rw-r--r--services/users/migrations/0009_delete_captchastore.py16
-rw-r--r--services/users/migrations/0010_userprofile_blinkie_url.py17
-rw-r--r--services/users/migrations/0011_tokenstore.py42
-rw-r--r--services/users/migrations/0012_alter_tokenstore_expires.py17
-rw-r--r--services/users/migrations/__init__.py0
-rw-r--r--services/users/models.py36
-rw-r--r--services/users/templates/email_change_verification_email.html27
-rw-r--r--services/users/templates/reset_password_email.html25
-rw-r--r--services/users/templates/verification_email.html26
-rw-r--r--services/users/tests.py3
-rw-r--r--services/users/tokens.py45
-rw-r--r--services/users/urls.py31
-rw-r--r--services/users/views.py300
27 files changed, 1186 insertions, 0 deletions
diff --git a/services/users/__init__.py b/services/users/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/services/users/__init__.py
diff --git a/services/users/accountFunctions.py b/services/users/accountFunctions.py
new file mode 100644
index 00000000..036b549a
--- /dev/null
+++ b/services/users/accountFunctions.py
@@ -0,0 +1,59 @@
+import secrets
+import uuid
+
+from django.utils import timezone
+
+from services.users.models import TokenStore, UserProfile
+
+
+def generate_token():
+ uid = uuid.uuid4().hex
+ token = secrets.token_urlsafe(32)
+ print(uid, token)
+ return uid, token
+
+
+def store_token(token_type, user, email=None):
+ previous_tokens = TokenStore.objects.filter(user=user, token_type=token_type)
+ if previous_tokens.exists():
+ previous_tokens.delete()
+ uid, token = generate_token()
+ token_store = TokenStore.objects.create(
+ user=user,
+ email=email if email is not None else user.email,
+ uid=uid,
+ token=token,
+ token_type=token_type,
+ expires=timezone.now() + timezone.timedelta(minutes=30),
+ )
+ token_store.save()
+ return uid, token
+
+
+def verify_token(token_type, uid, token, hold_verification=False):
+ try:
+ token_store = TokenStore.objects.get(
+ token_type=token_type, uid=uid, token=token
+ )
+ if (
+ token_store.expires > timezone.now()
+ and not token_store.verified
+ and token_store.token_type == token_type
+ and token_store.uid == uid
+ and token_store.token == token
+ ):
+
+ if hold_verification:
+ return token_store
+ token_store.verified = True
+
+ if token_type == "verifyemail":
+ UserProfile.objects.filter(user=token_store.user).update(
+ email_verified=True
+ )
+
+ token_store.save()
+
+ return token_store
+ except TokenStore.DoesNotExist:
+ return None
diff --git a/services/users/admin.py b/services/users/admin.py
new file mode 100644
index 00000000..0f1d5fdb
--- /dev/null
+++ b/services/users/admin.py
@@ -0,0 +1,7 @@
+from django.contrib import admin
+
+# Register your models here.
+from .models import TokenStore, UserProfile
+
+admin.site.register(UserProfile)
+admin.site.register(TokenStore)
diff --git a/services/users/apps.py b/services/users/apps.py
new file mode 100644
index 00000000..dafff467
--- /dev/null
+++ b/services/users/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class UsersConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "services.users"
diff --git a/services/users/forms.py b/services/users/forms.py
new file mode 100644
index 00000000..39d503fc
--- /dev/null
+++ b/services/users/forms.py
@@ -0,0 +1,279 @@
+# Registration form
+
+import string
+from random import choice
+
+from django import forms
+from django.contrib.auth.models import User
+from django.template.loader import render_to_string
+from django.utils.html import strip_tags
+
+from apps.blog.context_processors import avatar_list
+from services.users.models import UserProfile
+
+from .accountFunctions import store_token
+from .mail_send import send_email
+
+
+class RegisterForm(forms.Form):
+ username = forms.CharField(
+ label="Username",
+ max_length=30,
+ min_length=4,
+ required=True,
+ widget=forms.TextInput(
+ attrs={"placeholder": "Username", "autocomplete": "off"}
+ ),
+ )
+ email = forms.EmailField(
+ label="Email",
+ max_length=255,
+ required=True,
+ widget=forms.EmailInput(attrs={"placeholder": "Email", "autocomplete": "off"}),
+ )
+ password1 = forms.CharField(
+ label="Password",
+ min_length=8,
+ required=True,
+ widget=forms.PasswordInput(attrs={"placeholder": "Password"}),
+ )
+ password2 = forms.CharField(
+ label="Password (again)",
+ widget=forms.PasswordInput(attrs={"placeholder": "Password (again)"}),
+ min_length=8,
+ required=True,
+ )
+ captcha = forms.CharField(
+ label="Captcha",
+ max_length=6,
+ min_length=6,
+ required=True,
+ widget=forms.TextInput(attrs={"placeholder": "Captcha", "autocomplete": "off"}),
+ )
+ expected_captcha = None
+ protected_usernames = [
+ "admin",
+ "administrator",
+ "root",
+ "thatcomputerscientist",
+ "skippy",
+ "system",
+ "test",
+ "user",
+ "webmaster",
+ "www",
+ "postmaster",
+ "hostmaster",
+ "info",
+ "support",
+ "anonymous",
+ "guest",
+ "nobody",
+ "someone",
+ "moderator",
+ "moderators",
+ "mods",
+ "crvs",
+ ]
+ allowed_chars = string.ascii_letters + string.digits
+
+ def __init__(self, *args, **kwargs):
+ if "expected_captcha" in kwargs:
+ self.expected_captcha = kwargs.pop("expected_captcha")
+ super().__init__(*args, **kwargs)
+
+ def clean(self):
+ cleaned_data = super().clean()
+ password1 = cleaned_data.get("password1")
+ password2 = cleaned_data.get("password2")
+ captcha = cleaned_data.get("captcha")
+ if password1 and password2:
+ if password1 != password2:
+ raise forms.ValidationError("Passwords do not match.")
+ if len(password1) < 8:
+ raise forms.ValidationError("Password must be at least 8 characters long.")
+ if str.lower(captcha) != str.lower(self.expected_captcha):
+ raise forms.ValidationError("Captcha does not match.")
+ if User.objects.filter(username=cleaned_data.get("username")).exists():
+ raise forms.ValidationError(
+ "Username not available. Please choose another."
+ )
+ if cleaned_data.get("username").lower() in self.protected_usernames:
+ raise forms.ValidationError(
+ "Username not available. Please choose another."
+ )
+ for char in cleaned_data.get("username"):
+ if char not in self.allowed_chars:
+ raise forms.ValidationError(
+ "Username contains invalid characters. Only A-Z, a-z, and 0-9 are allowed."
+ )
+ if User.objects.filter(email=cleaned_data.get("email")).exists():
+ raise forms.ValidationError(
+ "Email already exists. Please login if this account is yours."
+ )
+ return cleaned_data
+
+ def save(self, request):
+ user = User.objects.create_user(
+ username=self.cleaned_data.get("username").lower(),
+ email=self.cleaned_data.get("email").lower(),
+ password=self.cleaned_data.get("password1"),
+ )
+ user.save()
+ user_profile = UserProfile.objects.create(user=user)
+ avatar_dir = choice(list(avatar_list().keys()))
+ avatar_file = choice(avatar_list()[avatar_dir])
+ user_profile.avatar_url = avatar_dir + "/" + avatar_file.replace(".gif", "")
+ user_profile.save()
+
+ uid, token = store_token(token_type="verifyemail", user=user, email=user.email)
+
+ # Send verification email
+ subject = "Verify your email address"
+ message = render_to_string(
+ "verification_email.html",
+ {
+ "user": user.username if user.first_name is None else user.first_name,
+ "site_name": "Shifoo",
+ "uid": uid,
+ "token": token,
+ "protocol": "https://" if request.is_secure() else "http://",
+ "domain": request.get_host(),
+ },
+ )
+ # message = strip_tags(message)
+ # send_mail(subject, message, 'Shifoo <' + settings.EMAIL_HOST_USER + '>', [user.email], fail_silently=False)
+ if send_email(
+ sender="[email protected]",
+ sender_name="Shifoo",
+ recipient=user.email,
+ subject=subject,
+ body_html=message,
+ body_text=message,
+ ):
+ return user
+ else:
+ return user
+
+
+class ForgotPasswordForm(forms.Form):
+ email = forms.EmailField(label="Email", required=True)
+
+ def clean(self):
+ cleaned_data = super().clean()
+ return cleaned_data
+
+ def save(self, request):
+ email = self.cleaned_data.get("email")
+ user = User.objects.get(email=email)
+ uid, token = store_token(
+ token_type="resetpassword", user=user, email=user.email
+ )
+ subject = "Reset your password"
+ message = render_to_string(
+ "reset_password_email.html",
+ {
+ "user": user.username if user.first_name is None else user.first_name,
+ "site_name": "Shifoo",
+ "uid": uid,
+ "token": token,
+ "protocol": "https://" if request.is_secure() else "http://",
+ "domain": request.get_host(),
+ },
+ )
+ # message = strip_tags(message)
+ if send_email(
+ sender="[email protected]",
+ sender_name="Shifoo",
+ recipient=user.email,
+ subject=subject,
+ body_html=message,
+ body_text=message,
+ ):
+ return user
+ else:
+ raise forms.ValidationError("Failed to send email.")
+
+
+class ResetPasswordForm(forms.Form):
+ password1 = forms.CharField(
+ label="New Password", widget=forms.PasswordInput, min_length=8
+ )
+ password2 = forms.CharField(
+ label="New Password (again)", widget=forms.PasswordInput, min_length=8
+ )
+
+ def clean(self):
+ cleaned_data = super().clean()
+ password1 = cleaned_data.get("password1")
+ password2 = cleaned_data.get("password2")
+ if password1 and password2:
+ if password1 != password2:
+ raise forms.ValidationError("Passwords do not match.")
+ if len(password1) < 8:
+ raise forms.ValidationError("Password must be at least 8 characters long.")
+ return cleaned_data
+
+ def save(self, user):
+ user.set_password(self.cleaned_data.get("password1"))
+ user.save()
+ return user
+
+
+class UpdateUserDetailsForm(forms.Form):
+ first_name = forms.CharField(
+ label="First name",
+ max_length=30,
+ required=False,
+ widget=forms.TextInput(attrs={"placeholder": "First name"}),
+ )
+ last_name = forms.CharField(
+ label="Last name",
+ max_length=30,
+ required=False,
+ widget=forms.TextInput(attrs={"placeholder": "Last name"}),
+ )
+ location = forms.CharField(
+ label="Location",
+ max_length=30,
+ required=False,
+ widget=forms.TextInput(attrs={"placeholder": "Location"}),
+ )
+ bio = forms.CharField(
+ label="Bio",
+ max_length=500,
+ required=False,
+ widget=forms.Textarea(attrs={"placeholder": "Bio"}),
+ )
+ is_public = forms.ChoiceField(
+ label="Activity Visibility",
+ choices=((True, "Public"), (False, "Private")),
+ widget=forms.RadioSelect,
+ )
+ email_public = forms.ChoiceField(
+ label="Email Visibility",
+ choices=((True, "Public"), (False, "Private")),
+ widget=forms.RadioSelect,
+ )
+
+ def __init__(self, *args, **kwargs):
+ self.user = kwargs.pop("user")
+ super().__init__(*args, **kwargs)
+
+ def clean(self):
+ cleaned_data = super().clean()
+ return cleaned_data
+
+ def save(self):
+ self.user.first_name = self.cleaned_data.get("first_name")
+ self.user.last_name = self.cleaned_data.get("last_name")
+ self.user.save()
+
+ user_profile = UserProfile.objects.get(user=self.user)
+ user_profile.location = self.cleaned_data.get("location")
+ user_profile.bio = self.cleaned_data.get("bio")
+ user_profile.is_public = self.cleaned_data.get("is_public")
+ user_profile.email_public = self.cleaned_data.get("email_public")
+ user_profile.save()
+
+ return (self.user, user_profile)
diff --git a/services/users/mail_send.py b/services/users/mail_send.py
new file mode 100644
index 00000000..df837a32
--- /dev/null
+++ b/services/users/mail_send.py
@@ -0,0 +1,75 @@
+# python script for sending SMTP configuration with Oracle Cloud Infrastructure Email Delivery
+import email.utils
+import smtplib
+import ssl
+from email.message import EmailMessage
+
+from django.conf import settings
+
+
+def send_email(sender, sender_name, recipient, subject, body_html, body_text):
+ # Replace [email protected] with your "From" address.
+ # This address must be verified.
+ # this is the approved sender email
+ SENDER = sender
+ SENDERNAME = sender_name
+
+ # Replace [email protected] with a "To" address. If your account
+ # is still in the sandbox, this address must be verified.
+ RECIPIENT = recipient
+
+ # Replace the USERNAME_SMTP value with your Email Delivery SMTP username.
+ USERNAME_SMTP = settings.USERNAME_SMTP
+
+ # Put the PASSWORD value from your Email Delivery SMTP password into the following file.
+ PASSWORD_SMTP = settings.PASSWORD_SMTP
+
+ # If you're using Email Delivery in a different region, replace the HOST value with an appropriate SMTP endpoint.
+ # Use port 25 or 587 to connect to the SMTP endpoint.
+ HOST = settings.EMAIL_HOST
+ PORT = settings.EMAIL_PORT
+
+ # The subject line of the email.
+ SUBJECT = subject
+
+ # The email body for recipients with non-HTML email clients.
+ BODY_TEXT = body_text
+
+ # The HTML body of the email.
+ BODY_HTML = body_html
+
+ # create message container
+ msg = EmailMessage()
+ msg["Subject"] = SUBJECT
+ msg["From"] = email.utils.formataddr((SENDERNAME, SENDER))
+ msg["To"] = RECIPIENT
+
+ # make the message multi-part alternative, making the content the first part
+ msg.add_alternative(BODY_TEXT, subtype="text")
+ # this adds the additional part to the message
+ # According to RFC 2046, the last part of a multipart message, in this case
+ # the HTML message, is best and preferred.
+ msg.add_alternative(BODY_HTML, subtype="html")
+
+ # Try to send the message.
+ try:
+ server = smtplib.SMTP(HOST, PORT)
+ server.ehlo()
+ # most python runtimes default to a set of trusted public CAs that will include the CA used by OCI Email Delivery.
+ # However, on platforms lacking that default (or with an outdated set of CAs), customers may need to provide a capath that includes our public CA.
+ server.starttls(
+ context=ssl.create_default_context(
+ purpose=ssl.Purpose.SERVER_AUTH, cafile=None, capath=None
+ )
+ )
+ # smtplib docs recommend calling ehlo() before & after starttls()
+ server.ehlo()
+ server.login(USERNAME_SMTP, PASSWORD_SMTP)
+ # our requirement is that SENDER is the same as From address set previously
+ server.sendmail(SENDER, RECIPIENT, msg.as_string())
+ server.close()
+ # Display an error message if something goes wrong.
+ except Exception as e:
+ return e
+ else:
+ return True
diff --git a/services/users/migrations/0001_initial.py b/services/users/migrations/0001_initial.py
new file mode 100644
index 00000000..a8a78c6b
--- /dev/null
+++ b/services/users/migrations/0001_initial.py
@@ -0,0 +1,29 @@
+# Generated by Django 4.0.6 on 2022-07-29 15:58
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='UserProfile',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('location', models.CharField(blank=True, max_length=50)),
+ ('bio', models.TextField(blank=True)),
+ ('gravatar_email', models.EmailField(blank=True, max_length=254)),
+ ('is_public', models.BooleanField(default=False)),
+ ('email_public', models.BooleanField(default=False)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/services/users/migrations/0002_userprofile_email_verified.py b/services/users/migrations/0002_userprofile_email_verified.py
new file mode 100644
index 00000000..ca89b815
--- /dev/null
+++ b/services/users/migrations/0002_userprofile_email_verified.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.6 on 2022-07-29 18:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='userprofile',
+ name='email_verified',
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/services/users/migrations/0003_captchastore.py b/services/users/migrations/0003_captchastore.py
new file mode 100644
index 00000000..9aebc0aa
--- /dev/null
+++ b/services/users/migrations/0003_captchastore.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.0.6 on 2022-09-05 22:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0002_userprofile_email_verified'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='CaptchaStore',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('captcha_string', models.CharField(max_length=6)),
+ ('csrf_token', models.CharField(max_length=100)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ],
+ ),
+ ]
diff --git a/services/users/migrations/0004_remove_captchastore_id_alter_captchastore_csrf_token.py b/services/users/migrations/0004_remove_captchastore_id_alter_captchastore_csrf_token.py
new file mode 100644
index 00000000..1dd16bcc
--- /dev/null
+++ b/services/users/migrations/0004_remove_captchastore_id_alter_captchastore_csrf_token.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.0.6 on 2022-09-05 22:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0003_captchastore'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='captchastore',
+ name='id',
+ ),
+ migrations.AlterField(
+ model_name='captchastore',
+ name='csrf_token',
+ field=models.CharField(max_length=100, primary_key=True, serialize=False),
+ ),
+ ]
diff --git a/services/users/migrations/0005_captchastore_id_alter_captchastore_csrf_token.py b/services/users/migrations/0005_captchastore_id_alter_captchastore_csrf_token.py
new file mode 100644
index 00000000..bc3d86e5
--- /dev/null
+++ b/services/users/migrations/0005_captchastore_id_alter_captchastore_csrf_token.py
@@ -0,0 +1,24 @@
+# Generated by Django 4.0.6 on 2022-09-05 23:41
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0004_remove_captchastore_id_alter_captchastore_csrf_token'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='captchastore',
+ name='id',
+ field=models.BigAutoField(auto_created=True, default=1, primary_key=True, serialize=False, verbose_name='ID'),
+ preserve_default=False,
+ ),
+ migrations.AlterField(
+ model_name='captchastore',
+ name='csrf_token',
+ field=models.CharField(max_length=100),
+ ),
+ ]
diff --git a/services/users/migrations/0006_delete_captchastore.py b/services/users/migrations/0006_delete_captchastore.py
new file mode 100644
index 00000000..f324ff2f
--- /dev/null
+++ b/services/users/migrations/0006_delete_captchastore.py
@@ -0,0 +1,16 @@
+# Generated by Django 4.0.6 on 2022-09-05 23:47
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0005_captchastore_id_alter_captchastore_csrf_token'),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name='CaptchaStore',
+ ),
+ ]
diff --git a/services/users/migrations/0007_captchastore.py b/services/users/migrations/0007_captchastore.py
new file mode 100644
index 00000000..15ac458a
--- /dev/null
+++ b/services/users/migrations/0007_captchastore.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.0.6 on 2022-09-05 23:48
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0006_delete_captchastore'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='CaptchaStore',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('captcha_string', models.CharField(max_length=6)),
+ ('csrf_token', models.CharField(max_length=100)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ],
+ ),
+ ]
diff --git a/services/users/migrations/0008_remove_userprofile_gravatar_email_and_more.py b/services/users/migrations/0008_remove_userprofile_gravatar_email_and_more.py
new file mode 100644
index 00000000..07f33320
--- /dev/null
+++ b/services/users/migrations/0008_remove_userprofile_gravatar_email_and_more.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.0.6 on 2022-12-28 10:05
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0007_captchastore'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='userprofile',
+ name='gravatar_email',
+ ),
+ migrations.AddField(
+ model_name='userprofile',
+ name='avatar_url',
+ field=models.TextField(blank=True),
+ ),
+ ]
diff --git a/services/users/migrations/0009_delete_captchastore.py b/services/users/migrations/0009_delete_captchastore.py
new file mode 100644
index 00000000..dbde60c8
--- /dev/null
+++ b/services/users/migrations/0009_delete_captchastore.py
@@ -0,0 +1,16 @@
+# Generated by Django 4.1.4 on 2022-12-28 12:41
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("users", "0008_remove_userprofile_gravatar_email_and_more"),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name="CaptchaStore",
+ ),
+ ]
diff --git a/services/users/migrations/0010_userprofile_blinkie_url.py b/services/users/migrations/0010_userprofile_blinkie_url.py
new file mode 100644
index 00000000..06f5e53c
--- /dev/null
+++ b/services/users/migrations/0010_userprofile_blinkie_url.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.1.4 on 2023-04-04 01:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("users", "0009_delete_captchastore"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="userprofile",
+ name="blinkie_url",
+ field=models.TextField(blank=True, default=""),
+ ),
+ ]
diff --git a/services/users/migrations/0011_tokenstore.py b/services/users/migrations/0011_tokenstore.py
new file mode 100644
index 00000000..f0bf9330
--- /dev/null
+++ b/services/users/migrations/0011_tokenstore.py
@@ -0,0 +1,42 @@
+# Generated by Django 4.1.4 on 2023-04-30 03:19
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("users", "0010_userprofile_blinkie_url"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="TokenStore",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("uid", models.TextField(unique=True)),
+ ("token", models.TextField(unique=True)),
+ ("email", models.EmailField(blank=True, max_length=254)),
+ ("token_type", models.CharField(max_length=50)),
+ ("expires", models.DateTimeField(auto_now_add=True)),
+ ("verified", models.BooleanField(default=False)),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/services/users/migrations/0012_alter_tokenstore_expires.py b/services/users/migrations/0012_alter_tokenstore_expires.py
new file mode 100644
index 00000000..4b3481fd
--- /dev/null
+++ b/services/users/migrations/0012_alter_tokenstore_expires.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.1.4 on 2023-05-03 01:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("users", "0011_tokenstore"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="tokenstore",
+ name="expires",
+ field=models.DateTimeField(),
+ ),
+ ]
diff --git a/services/users/migrations/__init__.py b/services/users/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/services/users/migrations/__init__.py
diff --git a/services/users/models.py b/services/users/models.py
new file mode 100644
index 00000000..f11f2f46
--- /dev/null
+++ b/services/users/models.py
@@ -0,0 +1,36 @@
+from django.conf import settings
+from django.db import models
+
+
+# User Profile Model
+class UserProfile(models.Model):
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ )
+ location = models.CharField(max_length=50, blank=True)
+ bio = models.TextField(blank=True)
+ avatar_url = models.TextField(blank=True)
+ is_public = models.BooleanField(default=False)
+ email_public = models.BooleanField(default=False)
+ email_verified = models.BooleanField(default=False)
+ blinkie_url = models.TextField(blank=True, default='')
+
+ def __str__(self):
+ return self.user.username
+
+
+class TokenStore(models.Model):
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ )
+ uid = models.TextField(unique=True)
+ token = models.TextField(unique=True)
+ email = models.EmailField(blank=True)
+ token_type = models.CharField(max_length=50)
+ expires = models.DateTimeField()
+ verified = models.BooleanField(default=False)
+
+ def __str__(self):
+ return self.user.username
diff --git a/services/users/templates/email_change_verification_email.html b/services/users/templates/email_change_verification_email.html
new file mode 100644
index 00000000..f6f9b127
--- /dev/null
+++ b/services/users/templates/email_change_verification_email.html
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Change Email</title>
+ </head>
+ <body>
+ <h3>Change Your Current Email</h3>
+ <p>Hi {{ user }},</p>
+ <p>
+ We received a request to change your email address on {{ site_name }}. To
+ verify and change your email address, please click the link below.
+ </p>
+ <a
+ href="{{ protocol }}{{ domain }}{% url 'users:changeemail' 'changeemail' uid token %}"
+ >Change Email</a
+ >
+ <p>If the above link does not work, copy and paste the URL below into your browser:</p>
+ <a href="{{ protocol }}{{ domain }}{% url 'users:changeemail' 'changeemail' uid token %}">{{ protocol }}{{ domain }}{% url 'users:changeemail' 'changeemail' uid token %}</a>
+
+ <p>Please ignore this email if you did not make this request.</p>
+ <p>Thanks,</p>
+ <p>Bobby from {{ site_name }}</p>
+ </body>
+</html>
diff --git a/services/users/templates/reset_password_email.html b/services/users/templates/reset_password_email.html
new file mode 100644
index 00000000..f9651a13
--- /dev/null
+++ b/services/users/templates/reset_password_email.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Reset Password</title>
+ </head>
+ <body>
+ <h3>Reset Your Current Password</h3>
+ <p>Hi {{ user }},</p>
+ <p>
+ We received a request to reset your password on {{ site_name }}. To reset
+ your password, please click the link below.
+ </p>
+ <a href="{{ protocol }}{{ domain }}{% url 'blog:resetpassword' uid token %}"
+ >Reset Password</a
+ >
+ <p>If the above link does not work, copy and paste the URL below into your browser:</p>
+ <a href="{{ protocol }}{{ domain }}{% url 'blog:resetpassword' uid token %}">{{ protocol }}{{ domain }}{% url 'blog:resetpassword' uid token %}</a>
+ <p>Please ignore this email if you did not make this request.</p>
+ <p>Thanks,</p>
+ <p>Bobby from {{ site_name }}</p>
+ </body>
+</html>
diff --git a/services/users/templates/verification_email.html b/services/users/templates/verification_email.html
new file mode 100644
index 00000000..06e91a79
--- /dev/null
+++ b/services/users/templates/verification_email.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Verify Email</title>
+ </head>
+ <body>
+ <h1>Verify Your New Account</h1>
+ <p>Hi {{ user }},</p>
+ <p>
+ Thanks for registering an account on {{ site_name }}. To verify your email
+ address, please click the link below.
+ </p>
+ <a
+ href="{{ protocol }}{{ domain }}{% url 'users:changeemail' 'verifyemail' uid token %}"
+ >Verify Email</a
+ >
+ <p>If the above link does not work, copy and paste the URL below into your browser:</p>
+ <a href="{{ protocol }}{{ domain }}{% url 'users:changeemail' 'verifyemail' uid token %}">{{ protocol }}{{ domain }}{% url 'users:changeemail' 'verifyemail' uid token %}</a>
+ <p>Please ignore this email if you did not make this request.</p>
+ <p>Thanks,</p>
+ <p>Bobby from {{ site_name }}</p>
+ </body>
+</html>
diff --git a/services/users/tests.py b/services/users/tests.py
new file mode 100644
index 00000000..7ce503c2
--- /dev/null
+++ b/services/users/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/services/users/tokens.py b/services/users/tokens.py
new file mode 100644
index 00000000..05d73362
--- /dev/null
+++ b/services/users/tokens.py
@@ -0,0 +1,45 @@
+import os
+
+from Crypto.Cipher import AES
+from django.contrib.auth.tokens import PasswordResetTokenGenerator
+from dotenv import load_dotenv
+from six import text_type
+
+load_dotenv()
+
+class AccountActivationTokenGenerator(PasswordResetTokenGenerator):
+ def _make_hash_value(self, user, timestamp):
+ return (
+ text_type(user.pk) + text_type(timestamp) +
+ text_type(user.is_active)
+ )
+
+class EmailChangeTokenGenerator():
+ def encrypt(self, email):
+ auth_string = os.getenv('AUTHORIZATION_STRING')
+ key = auth_string.encode('utf-8')[0:16]
+ cipher = AES.new(key, AES.MODE_CFB, key)
+ return cipher.encrypt(email.encode('utf-8')).hex()
+
+
+ def decrypt(self, token):
+ auth_string = os.getenv('AUTHORIZATION_STRING')
+ key = auth_string.encode('utf-8')[0:16]
+ cipher = AES.new(key, AES.MODE_CFB, key)
+ return cipher.decrypt(bytes.fromhex(token)).decode('utf-8')
+
+
+class CaptchaTokenGenerator():
+ def encrypt(self, captcha_string):
+ auth_string = os.getenv('AUTHORIZATION_STRING')
+ key = auth_string.encode('utf-8')[0:16]
+ cipher = AES.new(key, AES.MODE_CFB, key)
+ return cipher.encrypt(captcha_string.encode('utf-8')).hex()
+
+ def decrypt(self, token):
+ auth_string = os.getenv('AUTHORIZATION_STRING')
+ key = auth_string.encode('utf-8')[0:16]
+ cipher = AES.new(key, AES.MODE_CFB, key)
+ return cipher.decrypt(bytes.fromhex(token)).decode('utf-8')
+
+account_activation_token = AccountActivationTokenGenerator()
diff --git a/services/users/urls.py b/services/users/urls.py
new file mode 100644
index 00000000..b7081e42
--- /dev/null
+++ b/services/users/urls.py
@@ -0,0 +1,31 @@
+from django.contrib import admin
+from django.urls import path
+
+from . import views
+
+app_name = "users"
+urlpatterns = [
+ path("/login", views.login_user, name="login"),
+ path("/logout", views.logout_user, name="logout"),
+ path("/update", views.update_user, name="update"),
+ path("/changepassword", views.change_password, name="changepassword"),
+ path(
+ "/sendchangeuseremail", views.send_change_user_email, name="sendchangeuseremail"
+ ),
+ path(
+ "/sendverificationemail",
+ views.send_verification_email,
+ name="sendverificationemail",
+ ),
+ path("/updateavatar", views.update_avatar, name="updateavatar"),
+ path("/updateblinkies", views.update_blinkie, name="updateblinkie"),
+ path("/delete", views.delete_user, name="delete"),
+ path("/<mode>/<uid>/<token>", views.verify_email, name="verifyemail"),
+ path("/<mode>/<uid>/<token>", views.verify_email, name="changeemail"),
+ path("/resetpassword/<uid>/<token>", views.reset_password, name="resetpassword"),
+]
+
+# Configure Admin Site
+admin.site.site_header = "Shifoo Administation"
+admin.site.site_title = "Shifoo"
+admin.site.index_title = "Administration Area"
diff --git a/services/users/views.py b/services/users/views.py
new file mode 100644
index 00000000..5dba135b
--- /dev/null
+++ b/services/users/views.py
@@ -0,0 +1,300 @@
+from django.contrib import messages
+from django.contrib.auth import authenticate, login, logout, update_session_auth_hash
+from django.contrib.auth.models import User
+from django.http import HttpResponse, HttpResponseRedirect
+from django.shortcuts import redirect, reverse
+from django.template.loader import render_to_string
+from django.utils.html import strip_tags
+
+from .accountFunctions import store_token, verify_token
+from .forms import UpdateUserDetailsForm
+from .mail_send import send_email
+from .models import UserProfile
+
+
+# Create your views here.
+def login_user(request):
+ # pass
+ next = request.POST.get("next", "blog:home")
+ username = request.POST["username"]
+ password = request.POST["password"]
+ if username == "" or password == "" or username is None or password is None:
+ # required fields are empty
+ messages.error(request, "RFEERR", extra_tags="loginError")
+ return HttpResponseRedirect(next + "?username=" + username)
+ else:
+ # check if email is verified
+ user = authenticate(request, username=username, password=password)
+ if user is not None:
+ try:
+ email_verified = UserProfile.objects.get(user=user.pk).email_verified
+ except:
+ # user has no profile
+ email_verified = False
+ if email_verified:
+ login(request, user)
+ return HttpResponseRedirect(next)
+ else:
+ # email not verified
+ messages.error(request, "ENVERR", extra_tags="loginError")
+ return HttpResponseRedirect(next + "?username=" + username)
+ else:
+ # invalid credentials
+ messages.error(request, "IUOPERR", extra_tags="loginError")
+ return HttpResponseRedirect(next + "?username=" + username)
+
+
+def logout_user(request):
+ logout(request)
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+
+
+def update_user(request):
+ user = request.user
+ if user is not None:
+ if request.method == "POST":
+ form = UpdateUserDetailsForm(request.POST, user=user)
+ if form.is_valid():
+ form.save()
+ messages.success(request, "Profile was successfully updated!")
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+ else:
+ messages.error(
+ request, "Unable to update profile! Please try again later."
+ )
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+ else:
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+ else:
+ messages.error(request, "You must be logged in to update your profile!")
+ return redirect("blog:home")
+
+
+def delete_user(request):
+ user = request.user
+ if user is not None:
+ if request.method == "POST":
+ password = request.POST["password"]
+ if user.check_password(password):
+ # delete user, all comments, user profile details, and all posts
+ user.delete()
+ messages.success(request, "Your account was successfully deleted!")
+ return redirect("blog:home")
+ else:
+ messages.error(request, "Incorrect password!")
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+ else:
+ messages.error(request, "Unable to delete account! Please try again later.")
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+ else:
+ messages.error(request, "You must be logged in to delete your account!")
+ return redirect("blog:home")
+
+
+def update_avatar(request):
+ user = request.user
+ if user is not None:
+ if request.method == "POST":
+ user_profile = UserProfile.objects.get(user=user)
+ user_profile.avatar_url = request.POST["avatar"]
+ user_profile.save()
+ messages.success(request, "Avatar was successfully updated!")
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+ else:
+ messages.error(request, "Unable to update avatar! Please try again later.")
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+ else:
+ messages.error(request, "You must be logged in to update your avatar!")
+ return redirect("blog:home")
+
+
+def update_blinkie(request):
+ user = request.user
+ if user is not None:
+ if request.method == "POST":
+ user_profile = UserProfile.objects.get(user=user)
+ user_profile.blinkie_url = request.POST["blinkie"]
+ user_profile.save()
+ messages.success(request, "Blinkie was successfully updated!")
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+ else:
+ messages.error(request, "Unable to update blinkie! Please try again later.")
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+ else:
+ messages.error(request, "You must be logged in to update your blinkie!")
+ return redirect("blog:home")
+
+
+def change_password(request):
+ username = request.user
+ old_password = request.POST["oldPassword"]
+ new_password = request.POST["newPassword"]
+ confirm_password = request.POST["confirmPassword"]
+ if username is not None:
+ user = User.objects.get(username=username)
+ if user.check_password(old_password):
+ if new_password == confirm_password:
+ if len(new_password) < 8:
+ messages.error(
+ request, "The new password must be at least 8 characters long!"
+ )
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+ user.set_password(new_password)
+ user.save()
+ update_session_auth_hash(request, user)
+ messages.success(request, "Password was successfully changed!")
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+ else:
+ messages.error(
+ request, "The new password and confirmation password do not match!"
+ )
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+ else:
+ messages.error(request, "Old password is incorrect!")
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+ else:
+ messages.error(request, "Unable to change password! Please try again later.")
+ return redirect("blog:home")
+
+
+def send_change_user_email(request):
+ user = request.user
+ new_email = request.POST["email"]
+ if user is not None:
+ # Check if the new and the old email are the same
+ if user.email == new_email:
+ messages.error(request, "New email is the same as the old one!")
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+
+ # check if email is already in use
+ if User.objects.filter(email=new_email).exists():
+ messages.error(request, "Email is already in use!")
+ # Redirect to referrer
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+ # Send verification email
+ subject = "Change your email address"
+ uid, token = store_token(token_type="changeemail", user=user, email=new_email)
+
+ message = render_to_string(
+ "email_change_verification_email.html",
+ {
+ "user": user.username if user.first_name is None else user.first_name,
+ "site_name": "Shifoo",
+ "uid": uid,
+ "token": token,
+ "protocol": request.scheme + "://",
+ "domain": request.get_host(),
+ },
+ )
+ # message = strip_tags(message)
+ # send_mail(subject, message, 'That Computer Scientist <' + settings.EMAIL_HOST_USER + '>', [new_email])
+
+ if send_email(
+ sender="[email protected]",
+ sender_name="Shifoo",
+ recipient=new_email,
+ subject=subject,
+ body_html=message,
+ body_text=message,
+ ):
+ messages.success(
+ request, "Verification email was sent! Please check your email."
+ )
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+ else:
+ messages.error(request, "Unable to change email! Please try again later.")
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+
+ else:
+ messages.error(request, "Unable to change email! Please try again later.")
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+
+
+def send_verification_email(request):
+ # this is a post only view
+ if request.method == "POST":
+ username = request.POST.get("username")
+ subject = "Verify your email address"
+ user = User.objects.get(username=username)
+ uid, token = store_token(token_type="verifyemail", user=user, email=user.email)
+
+ message = render_to_string(
+ "verification_email.html",
+ {
+ "user": user.username if user.first_name is None else user.first_name,
+ "site_name": "Shifoo",
+ "uid": uid,
+ "token": token,
+ "protocol": "https://" if request.is_secure() else "http://",
+ "domain": request.get_host(),
+ },
+ )
+ # message = strip_tags(message)
+ if send_email(
+ sender="[email protected]",
+ sender_name="Shifoo",
+ recipient=user.email,
+ subject=subject,
+ body_html=message,
+ body_text=message,
+ ):
+ messages.success(request, "VESENT", extra_tags="loginError")
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+ else:
+ messages.error(request, "VESENDERR", extra_tags="loginError")
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+ else:
+ messages.error(request, "VESENDERR", extra_tags="loginError")
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+
+
+def verify_email(request, mode, uid, token):
+ token_object = verify_token(mode, uid, token)
+ redirect_to = (
+ reverse("blog:account") + "?tab=email" if mode == "changeemail" else "blog:home"
+ )
+ success_message = (
+ "Email was successfully changed!" if mode == "changeemail" else "VESUCCESS"
+ )
+ error_message = "Unable to verify email! Please try again later."
+
+ if token_object is not None and token_object.verified:
+ user = User.objects.get(pk=token_object.user_id)
+ user.email = token_object.email
+ user.save()
+ token_object.delete()
+ messages.success(
+ request,
+ success_message,
+ extra_tags="loginError" if mode == "verifyemail" else "",
+ )
+ return redirect(redirect_to)
+ else:
+ messages.error(request, error_message)
+ return redirect(redirect_to)
+
+
+def reset_password(request, uid, token):
+ mode = "resetpassword"
+ token_object = verify_token(mode, uid, token)
+
+ # Token is not verified yet, but confirmed that it belongs to the user
+ # Now we send a form for the user to reset their password
+ if token_object is not None and token_object.verified:
+ print(token_object.user_id)
+ # redirect to forgotpassword/reset?uid=uid&token=token
+ return HttpResponseRedirect(
+ reverse("blog:resetpassword")
+ + "?uid="
+ + token_object.user_id
+ + "&token="
+ + token
+ )
+ else:
+ # Token is invalid
+ messages.error(
+ request,
+ "Unable to reset password! Please try again later.",
+ extra_tags="passwordReset",
+ )
+ return redirect("blog:forgotpassword")