Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 30 additions & 44 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Personal Website Project

A Django-based personal website project aimed at showcasing backend development skills while building the foundation for a professional portfolio platform. The project currently focuses on backend functionality, with a basic frontend skeleton to support future interactive features.
A Django-based personal portfolio platform focused on a passwordless ("magic link") authentication experience, plus a small collection of informational pages.

---

Expand All @@ -18,37 +18,17 @@ A Django-based personal website project aimed at showcasing backend development

## Overview

This Django project is a personal website designed to demonstrate backend engineering capabilities, while serving as a platform for a portfolio.

Current backend functionality includes:

- Passwordless user registration and login using **magic links** sent via email
- Email verification workflow to ensure valid accounts
- Mailing list opt-in system for newsletters and updates
- Custom User model storing extended information (email, name, verification status, consent)
- Basic frontend skeleton using HTML and Tailwind CSS for structure and forms

The project is intended to evolve into a fully interactive portfolio website with dynamic content and modern frontend enhancements.

---

## Technology Stack

- Python 3.11
- Django 5.x
- SQLite (default; can be swapped with PostgreSQL or other DB)
- Tailwind CSS (frontend skeleton)
This repository powers a personal portfolio site built with Django. The `core` app owns public-facing pages, while the `accounts` app implements a passwordless login flow. Users register with an email address, receive verification links, and later sign in via one-time "magic" links.

---

## Features

- **User Authentication**: Register and log in using secure, signed email tokens (magic links)
- **Email Verification**: Users must verify their email before gaining full access
- **Mailing List Consent**: Optional opt-in during registration for newsletters
- **Custom User Model**: Stores email, name, verification status, and mailing list preference
- **Backend-Focused Architecture**: Demonstrates session management, token signing, and secure flows
- **Basic HTML Templates**: Provide structural placeholders for future interactive pages
- Passwordless auth: email-based verification and login links that expire after first use.
- Rate limiting: built-in throttling on login and registration email requests to prevent abuse.
- Async mail dispatch: magic-link emails send in the background so requests stay fast.
- Custom user model: `accounts.User` uses email as the identifier.
- Core marketing pages: static landing/about/projects pages under the `core` app.

---

Expand All @@ -73,46 +53,52 @@ source .venv/bin/activate

3. Install dependencies:

```
```bash
pip install -r requirements.txt
```

4. Apply migrations:

```
```bash
python manage.py migrate
```

5. Run the development server:
5. Create a `.env` file (or otherwise supply environment variables):

```
DJANGO_SECRET_KEY=change-me
DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost
DJANGO_DEBUG=True
[email protected]
EMAIL_HOST_PASSWORD=app-specific-password
```

6. Run the development server:

```bash
python manage.py runserver
```

6. Access the site at: [http://127.0.0.1:8000/core](http://127.0.0.1:8000/core)
7. Access the site at http://127.0.0.1:8000/core/ (core app) or http://127.0.0.1:8000/accounts/ (auth flows).

---

## Usage

- Visit `/core` for the landing page with links to registration, login, and site sections.
- Registration allows users to opt-in to the mailing list.
- Users receive a verification email; clicking the link completes registration.
- Login uses a **magic link** sent to the registered email—no password required.
- Templates currently provide basic structure and forms; frontend enhancements will be added in future updates.
- Email testing can be done via Django console backend or a configured SMTP server.
- Register: POST `/accounts/register/` with an email/name to receive a verification link.
- Verify: click the emailed `/accounts/verify/?token=...` link to activate the account.
- Login: POST `/accounts/login/` to receive a single-use login link.
- Landing pages live under `/core/` and are safe for non-authenticated traffic.

Rate limits currently allow **3 registration attempts/hour** and **5 login-link requests/15 minutes** per email address.

---

## Future Development

- Expand frontend with **Tailwind CSS or React** for interactive, polished UI
- Implement **comment and like system** for portfolio projects
- Add **user dashboards and profiles** for logged-in users
- Enhance session management (session expiration, "remember me")
- Integrate analytics for project interactions and views
- Implement real mailing list integration (Mailchimp, SendGrid, etc.)
- Add additional backend features for a fully dynamic portfolio experience
- Improve user feedback and UI polish for auth flows.
- Expand portfolio content with dynamic project data.
- Integrate background task queue for email delivery if traffic grows.

---

Expand Down
17 changes: 14 additions & 3 deletions my_website/accounts/managers.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
from django.contrib.auth.models import BaseUserManager


class UserManager(BaseUserManager):
def create_user(self, email, name, password=None, **extra_fields):
if not email:
raise ValueError("Users must have a valid email adress")

email = self.normalize_email(email)
user = self.model(email=email, name=name, **extra_fields)
user.set_unusable_password()
user.save()

if password:
user.set_password(password)
else:
user.set_unusable_password()

user.save(using=self._db)
return user

def create_superuser(self, email, name, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
return self.create_user(email, name, **extra_fields)

if not password:
raise ValueError("Superusers must have a password.")

return self.create_user(email, name, password=password, **extra_fields)
24 changes: 24 additions & 0 deletions my_website/accounts/migrations/0002_magiclink.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from django.db import migrations, models
import django.utils.timezone
import uuid


class Migration(migrations.Migration):

dependencies = [
('accounts', '0001_initial'),
]

operations = [
migrations.CreateModel(
name='MagicLink',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('email', models.EmailField(max_length=254)),
('token_type', models.CharField(choices=[('login', 'Login'), ('verify', 'Verify')], max_length=10)),
('expires_at', models.DateTimeField()),
('used_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
]
32 changes: 31 additions & 1 deletion my_website/accounts/models.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.db import models
from django.utils import timezone
import uuid

from .managers import UserManager

# Create your models here.

class User(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(unique=True)
name = models.CharField(max_length=255)
Expand All @@ -19,3 +21,31 @@ class User(AbstractBaseUser, PermissionsMixin):

def __str__(self):
return self.email


class MagicLink(models.Model):
class TokenType(models.TextChoices):
LOGIN = "login", "Login"
VERIFY = "verify", "Verify"

id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
email = models.EmailField()
token_type = models.CharField(max_length=10, choices=TokenType.choices)
expires_at = models.DateTimeField()
used_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)

def mark_used(self):
self.used_at = timezone.now()
self.save(update_fields=["used_at"])

@property
def is_expired(self):
return timezone.now() >= self.expires_at

@property
def is_used(self):
return self.used_at is not None

def __str__(self):
return f"{self.token_type} magic link for {self.email}"
17 changes: 17 additions & 0 deletions my_website/accounts/rate_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from django.core.cache import cache


def _cache_key(prefix: str, identifier: str) -> str:
return f"rate:{prefix}:{identifier}"


def is_rate_limited(prefix: str, identifier: str, limit: int, window: int) -> bool:
"""
Returns True when the identifier exceeded the limit within the window (seconds).
"""
key = _cache_key(prefix, identifier)
current = cache.get(key, 0)
if current >= limit:
return True
cache.set(key, current + 1, window)
return False
47 changes: 47 additions & 0 deletions my_website/accounts/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import asyncio
import os
from functools import partial

from asgiref.sync import async_to_sync
from django.core.mail import send_mail
from django.urls import reverse

from .tokens import generate_login_token, generate_verification_token

SENDER_EMAIL = os.getenv("EMAIL_HOST_USER")


def _build_magic_link(request, url_name, token):
return request.build_absolute_uri(f"{reverse(url_name)}?token={token}")


async def _send_mail_async(*args, **kwargs):
loop = asyncio.get_event_loop()
send = partial(send_mail, *args, **kwargs)
await loop.run_in_executor(None, send)


def send_verification_email(request, email):
token = generate_verification_token(email)
url = _build_magic_link(request, "accounts:verify", token)
async_to_sync(_send_mail_async)(
"Verify your account",
f"Click to verify your email:\n\n{url}",
SENDER_EMAIL,
[email],
fail_silently=False,
)
return url


def send_login_email(request, email):
token = generate_login_token(email)
url = _build_magic_link(request, "accounts:login_confirm", token)
async_to_sync(_send_mail_async)(
"Your login link",
f"Click here to log in:\n\n{url}",
SENDER_EMAIL,
[email],
fail_silently=False,
)
return url
37 changes: 35 additions & 2 deletions my_website/accounts/tests.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,36 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.urls import reverse

# Create your tests here.
from .tokens import generate_login_token, generate_verification_token, verify_login_token, verify_verification_token


@override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend')
class MagicLinkTests(TestCase):
def setUp(self):
cache.clear()
self.user_model = get_user_model()
self.user = self.user_model.objects.create_user(email="[email protected]", name="User", is_verified=True)

def test_login_token_single_use(self):
token = generate_login_token(self.user.email)
self.assertIsNotNone(verify_login_token(token))
self.assertIsNone(verify_login_token(token))

def test_verification_token_single_use(self):
token = generate_verification_token(self.user.email)
self.assertIsNotNone(verify_verification_token(token))
self.assertIsNone(verify_verification_token(token))

def test_login_rate_limit_blocks_after_threshold(self):
url = reverse("accounts:login")
data = {"email": self.user.email}

for _ in range(5):
response = self.client.post(url, data)
self.assertEqual(response.status_code, 200)

response = self.client.post(url, data)
self.assertEqual(response.status_code, 302)
self.assertRedirects(response, url)
Loading
Loading