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
14 changes: 13 additions & 1 deletion ev_backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,24 @@ JWT_ACCESS_MINUTES=30
JWT_REFRESH_DAYS=7

# ── Email (OTP delivery) ──────────────────────────────────────────────────
# Signup verification codes (W8) and PIN-reset codes are emailed. In production
# these MUST be set or the deploy check fails. Point DEFAULT_FROM_EMAIL at your
# real sender, e.g. support@zazatechnologies.com.
# EMAIL_HOST=smtp.gmail.com
# EMAIL_PORT=587
# EMAIL_USE_TLS=True
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
# DEFAULT_FROM_EMAIL=EV Charging <no-reply@example.com>
# DEFAULT_FROM_EMAIL=EV Charge Hub <support@zazatechnologies.com>

# ── Social sign-in (W8) ────────────────────────────────────────────────────
# Comma-separated OAuth client IDs a provider token's `aud` must match (the
# anti-forgery control in accounts/social.py). Leave empty to keep the provider
# disabled — authCapabilities then reports it false and no client offers the
# button. Google usually needs one ID per platform (web, Android, iOS); Apple's
# is the Services ID (web) and/or the app bundle ID.
# GOOGLE_OAUTH_CLIENT_IDS=xxxx.apps.googleusercontent.com,yyyy.apps.googleusercontent.com
# APPLE_CLIENT_IDS=com.zazatechnologies.evchargehub

# ── CORS / CSRF (production) ───────────────────────────────────────────────
# CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com
Expand Down
115 changes: 115 additions & 0 deletions ev_backend/accounts/email_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Signup email verification (Sprint W8).

Proving control of an EMAIL address at signup, and nothing else. What that proof
is then spent on — creating the phone-first account — belongs to
``services.create_phone_account``. Kept apart for the same reason
``phone_service`` is kept apart from ``identity``: "you hold this mailbox" is one
fact with one set of security properties; "and therefore this account is yours"
is a decision with several.

This is the EMAIL analogue of ``phone_service`` (SMS). It exists because the
platform has no SMS provider (see sms.py), so a phone-first signup proves the
person via the email they supply alongside the number. The consequence is
recorded wherever it matters: an account created this way has a PROVEN email and
a CLAIMED-not-proven phone. See docs/IDENTITY_ARCHITECTURE.md §9.2 and
``EmailVerification``.

Mirrors ``phone_service`` and ``services`` in its hardening because that design
is already right: rate-limited per IP and per identifier, constant-time
verification, attempt-limited, single-use.

THE ENUMERATION RULE: ``send_signup_otp`` returns the SAME message whether or not
the address is already registered. Signup does eventually have to tell the caller
"that email is taken" — but it says so at ``create_phone_account``, after a code
was proven, not from an unauthenticated send that would otherwise be a free
"is this email registered here" oracle.
"""

import logging

from django.contrib.auth import get_user_model

from ev_backend.errors import ValidationError

from . import ratelimit
from .auth_logging import log_event
from .models import EmailVerification

logger = logging.getLogger('accounts.email')

User = get_user_model()

#: The one reply every send path gives. See THE ENUMERATION RULE above.
GENERIC_CODE_SENT = "If that address can receive mail, a code has been sent."
GENERIC_CODE_INVALID = "Invalid or expired code."


def send_signup_otp(email, request=None):
"""Email a signup verification code. Returns the generic message.

Rate-limited per IP and per address, with a per-address cooldown. Delivery
failure is logged server-side (never with the code) and rolls the row back so
a cooldown is not left behind for a code that reached nobody — the same
contract ``phone_service.send_phone_otp`` keeps for SMS.
"""
email = (email or '').strip()
if not email or '@' not in email:
# A malformed address is the caller's typo, not an enumeration signal;
# refusing to say so would be user-hostile for no security gain.
raise ValidationError("Enter a valid email address.")

ip = ratelimit.get_client_ip(request)
ratelimit.enforce('OTP_REQUEST', ip, 'ip')
ratelimit.enforce('OTP_REQUEST', email.lower(), 'account')

ok, _wait = EmailVerification.can_request(email)
if not ok:
# Same reply as success: the cooldown must not be observable.
log_event('signup_otp_requested', request=request, result='cooldown')
return GENERIC_CODE_SENT

try:
EmailVerification.generate_for_email(email)
except Exception:
# Delivery failed: the row was rolled back inside generate_for_email's
# caller contract only if we do it here — generate_for_email persists
# then sends, so on failure the just-created row must go.
EmailVerification.objects.filter(email__iexact=email).delete()
log_event('signup_otp_send_failed', request=request, level=logging.ERROR)
# Stay generic to the caller: a delivery outage is our problem, and the
# reply is the same one every path gives.
return GENERIC_CODE_SENT

log_event('signup_otp_requested', request=request, result='sent')
return GENERIC_CODE_SENT


def verify_signup_otp(email, code, request=None):
"""Check a signup code. Returns the normalised email on success.

Raises ValidationError on any failure, with one message for every cause —
"wrong code", "expired" and "never requested" are the same to the caller, who
does the same thing in all three: ask for another one.
"""
email = (email or '').strip()
ip = ratelimit.get_client_ip(request)
ratelimit.enforce('OTP_VERIFY', ip, 'ip')
ratelimit.enforce('OTP_VERIFY', email.lower(), 'account')

verification = (
EmailVerification.objects.filter(email__iexact=email)
.order_by('-created_at')
.first()
)
if verification is None:
log_event('signup_otp_verify', request=request, result='no_code')
raise ValidationError(GENERIC_CODE_INVALID)

result = verification.verify(code)
if result != 'ok':
# `result` is logged for the operator, never returned to the caller.
log_event('signup_otp_verify', request=request, result=result)
raise ValidationError(GENERIC_CODE_INVALID)

log_event('signup_otp_verify', request=request, result='ok')
return email
31 changes: 31 additions & 0 deletions ev_backend/accounts/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from ev_backend.errors import Conflict, NotFound, ValidationError

from .models import AuthIdentity, User
from .phone import normalize_phone


def find_by_identity(provider, subject):
Expand Down Expand Up @@ -203,6 +204,36 @@ def attach_phone(*, user, phone_e164):
return user


def set_unverified_phone(*, user, phone_e164):
"""Attach a CLAIMED (not proven) phone to an account (W8).

The path a Google/Apple signup takes: the provider proved who they are, we
then ask for a phone for contact and booking, and there is no SMS to prove it
(sms.py). So this sets the canonical number WITHOUT a ``phone_verified_at``
and WITHOUT creating a ``phone`` identity — an identity row asserts a proof
that did not happen, and ``sign_in_with_identity`` would later trust it.

Contrast ``attach_phone``, which is the OTP-backed path: it stamps
``phone_verified_at`` and writes a verified ``phone`` identity because a code
was actually spent. The two must not converge — an unverified number that
reads as verified is exactly the lie the identity model refuses elsewhere.

Uniqueness is still enforced: the number is canonical and the login space, so
two accounts cannot claim it even unproven. The explicit check turns the
unique-constraint IntegrityError into a message the caller can show.
"""
e164 = normalize_phone(phone_e164)

clash = User.objects.filter(phone_e164=e164).exclude(pk=user.pk).exists()
if clash:
raise Conflict("That phone number is already in use on another account.")

user.phone_e164 = e164
# Deliberately NOT setting phone_verified_at: it was not proven.
user.save(update_fields=['phone_e164'])
return user


def revoke_all_sessions(*, user):
"""Invalidate every JWT ever issued to this user (W7 §5b).

Expand Down
27 changes: 27 additions & 0 deletions ev_backend/accounts/migrations/0011_emailverification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 5.2.7 on 2026-07-22 11:30

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('accounts', '0010_w7_phone_verification'),
]

operations = [
migrations.CreateModel(
name='EmailVerification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(db_index=True, max_length=254)),
('otp_hash', models.CharField(max_length=128)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
('attempts', models.PositiveSmallIntegerField(default=0)),
],
options={
'indexes': [models.Index(fields=['email', '-created_at'], name='emailverif_lookup_idx')],
},
),
]
138 changes: 138 additions & 0 deletions ev_backend/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,144 @@ def __str__(self):
return f"PhoneVerification({self.phone_e164})"


class EmailVerification(models.Model):
"""An email OTP proving control of an address at SIGNUP (Sprint W8).

A sibling of ``PasswordResetOTP``, not a reuse of it, for the same reason
``PhoneVerification`` is a sibling and not a reuse: same hardening, different
subject, different consequence. ``PasswordResetOTP`` is bound to an existing
``user`` — you already have an account, prove the mailbox to reset its PIN.
This one exists BEFORE any account, because signup is the first time the
platform meets the person. Binding to a user here is impossible (there is no
user yet), so it is keyed on the email string, exactly as
``PhoneVerification`` is keyed on the number.

WHY EMAIL AND NOT SMS for a phone-first product: there is no SMS provider
(see sms.py, and docs/IDENTITY_ARCHITECTURE.md §9.2). The signup code is
delivered to the email collected alongside the phone. This proves the EMAIL,
not the handset — so an account created this way leaves ``phone_verified_at``
NULL and gets no verified ``phone`` identity. The phone is canonical (it is
the login key and is UNIQUE) but claimed, not proven. Recording a phone
verification that never happened would be a lie the identity-linking rules
would later trust, which is the mistake ``AuthIdentity.verified_at`` exists to
avoid.

Separate table from ``PasswordResetOTP`` on purpose: a code minted to reset a
PIN must not be spendable to register an account, and vice versa. Sharing a
table would make a code for one purpose valid for the other — a privilege
change for the price of a typo, the same hazard ``PhoneVerification`` cites.

The security design is copied wholesale from ``PasswordResetOTP`` because that
design is right: the plaintext code is never stored, verification is
constant-time and attempt-limited, and the record dies on success, expiry or
exhaustion. Do not re-derive it.
"""

#: The address being proven. Not a FK — see the class docstring: at signup we
#: do not yet have (or want) a user row to hang this off.
email = models.EmailField(db_index=True)
otp_hash = models.CharField(max_length=128)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
attempts = models.PositiveSmallIntegerField(default=0)

class Meta:
indexes = [
models.Index(fields=['email', '-created_at'], name='emailverif_lookup_idx'),
]

# Delivery is email, same as PasswordResetOTP, so the same OTP_* knobs govern
# it. Phone's separate PHONE_OTP_* knobs exist because SMS has different cost
# and cooldown economics; email does not, so reusing these keeps one dial.
@staticmethod
def validity_minutes():
return getattr(settings, 'OTP_VALIDITY_MINUTES', 10)

@staticmethod
def max_attempts():
return getattr(settings, 'OTP_MAX_ATTEMPTS', 5)

@staticmethod
def cooldown_seconds():
return getattr(settings, 'OTP_REQUEST_COOLDOWN_SECONDS', 60)

@staticmethod
def otp_length():
return getattr(settings, 'OTP_LENGTH', 6)

@classmethod
def can_request(cls, email):
"""Cooldown gate. Returns ``(allowed, seconds_remaining)``."""
latest = cls.objects.filter(email__iexact=email).order_by('-created_at').first()
if latest is None:
return True, 0
elapsed = (timezone.now() - latest.created_at).total_seconds()
cooldown = cls.cooldown_seconds()
if elapsed < cooldown:
return False, int(cooldown - elapsed)
return True, 0

@classmethod
def generate_for_email(cls, email):
"""Mint a fresh code, store only its hash, and email it once.

Sends within this method — like ``PasswordResetOTP`` and unlike
``PhoneVerification`` — because email delivery is Django's ``send_mail``
with one obvious backend, not a swappable adapter that can legitimately
refuse. ``fail_silently=False`` propagates a delivery failure so the
caller can roll the row back rather than leave a cooldown behind for a
code nobody received.
"""
cls.objects.filter(email__iexact=email).delete() # invalidate any prior code

# secrets, not random: an OTP is a credential for its validity window.
code = "".join(secrets.choice("0123456789") for _ in range(cls.otp_length()))

instance = cls.objects.create(
email=email,
otp_hash=make_password(code),
expires_at=timezone.now() + timezone.timedelta(minutes=cls.validity_minutes()),
)
instance._send_email(code) # plaintext used transiently for delivery only
return instance

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

def verify(self, supplied):
"""``"ok"`` | ``"expired"`` | ``"locked"`` | ``"invalid"``. Single-use."""
if self.is_expired():
self.delete()
return 'expired'

if check_password(str(supplied), self.otp_hash):
self.delete()
return 'ok'

type(self).objects.filter(pk=self.pk).update(attempts=models.F('attempts') + 1)
self.refresh_from_db(fields=['attempts'])
if self.attempts >= self.max_attempts():
self.delete()
return 'locked'
return 'invalid'

def _send_email(self, code):
context = {"otp": code, "minutes": self.validity_minutes()}
txt_message = render_to_string("accounts/emails/signup_otp.txt", context)
html_message = render_to_string("accounts/emails/signup_otp.html", context)
send_mail(
subject="Your EV Charge Hub verification code",
message=txt_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[self.email],
html_message=html_message,
fail_silently=False,
)

def __str__(self):
return f"EmailVerification({self.email})"


class AuthIdentity(models.Model):
"""One way a user can prove who they are (Sprint W7).

Expand Down
Loading
Loading