diff --git a/ev_backend/.env.example b/ev_backend/.env.example index 46b7aba..6d8428b 100644 --- a/ev_backend/.env.example +++ b/ev_backend/.env.example @@ -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 +# DEFAULT_FROM_EMAIL=EV Charge Hub + +# ── 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 diff --git a/ev_backend/accounts/email_service.py b/ev_backend/accounts/email_service.py new file mode 100644 index 0000000..e8f3248 --- /dev/null +++ b/ev_backend/accounts/email_service.py @@ -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 diff --git a/ev_backend/accounts/identity.py b/ev_backend/accounts/identity.py index c55e3bf..3816bc1 100644 --- a/ev_backend/accounts/identity.py +++ b/ev_backend/accounts/identity.py @@ -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): @@ -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). diff --git a/ev_backend/accounts/migrations/0011_emailverification.py b/ev_backend/accounts/migrations/0011_emailverification.py new file mode 100644 index 0000000..b4134c2 --- /dev/null +++ b/ev_backend/accounts/migrations/0011_emailverification.py @@ -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')], + }, + ), + ] diff --git a/ev_backend/accounts/models.py b/ev_backend/accounts/models.py index 4fada30..9d8a00a 100644 --- a/ev_backend/accounts/models.py +++ b/ev_backend/accounts/models.py @@ -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). diff --git a/ev_backend/accounts/schema.py b/ev_backend/accounts/schema.py index 8a8d4a2..8707278 100644 --- a/ev_backend/accounts/schema.py +++ b/ev_backend/accounts/schema.py @@ -10,10 +10,9 @@ from .permission import admin_required, login_required, public from .models import AuditLog, AuthIdentity, PasswordResetOTP -from . import administration, identity, phone_service, services, ratelimit +from . import administration, email_service, identity, phone_service, services, ratelimit, social from .auth_logging import log_event from .phone import mask_phone -from .sms import sms_configured from ev_backend.errors import ValidationError from ev_backend.pagination import apply_ordering, hard_cap, paginate # `stations.schema` imports `accounts.permission`, never `accounts.schema`, so @@ -724,13 +723,18 @@ class AuthCapabilitiesType(graphene.ObjectType): """What sign-in methods this deployment can ACTUALLY perform right now. Exists so a client can disable a button and say why, instead of offering a - flow that is guaranteed to fail. Read from real configuration, not from - intent: `phoneSignIn` is false whenever no SMS provider is wired, which is - the default today. + flow that is guaranteed to fail. Read from real configuration, not intent. + + `phoneSignIn` means phone+PIN sign-in (W8), which needs no SMS provider and is + the primary method — so it is a constant true, not the W7 `sms_configured()` + check (passwordless phone-OTP is a separate, secondary path). `googleSignIn` + and `appleSignIn` are true only when that provider's client IDs are + configured; with none set, verification cannot succeed and the button must not + be offered (see accounts/social.py). """ phone_sign_in = graphene.Boolean( - required=True, description="Whether a phone code can actually be delivered." + required=True, description="Whether phone + PIN sign-in is available." ) google_sign_in = graphene.Boolean(required=True, description="Whether Google sign-in is available.") apple_sign_in = graphene.Boolean(required=True, description="Whether Apple sign-in is available.") @@ -870,6 +874,196 @@ def mutate(self, info): return LogoutEverywhere(success=True) +# ── Phone + PIN and social sign-in (Sprint W8) ─────────────────────────────── +# +# W7 made phone the canonical identity and shipped passwordless phone-OTP sign-in +# against an SMS seam that still has no provider. W8 makes phone + PIN the PRIMARY +# credential (no SMS needed to sign in) and adds Google and Apple, so the three +# options a client offers are: phone+PIN, Google, Apple. +# +# Signup proves the person with an EMAILED code (email_service), not an SMS one — +# there is still no SMS provider — so the phone is collected as canonical but +# unproven. As everywhere else, handlers stay thin over the service layer. + + +class SendSignupOtp(graphene.Mutation): + """Email a verification code to begin phone + PIN signup. + + Public: signup has no session by definition. The reply is identical whether or + not the address is already registered (email_service's enumeration rule); the + "already registered" answer comes later, from registerWithPhone, after a code + has been proven — never from an unauthenticated send that would otherwise be a + free "is this email registered here" oracle. + """ + + success = graphene.Boolean() + message = graphene.String() + + class Arguments: + email = graphene.String(required=True) + + @public + def mutate(self, info, email): + try: + message = email_service.send_signup_otp(email, request=info.context) + except ratelimit.RateLimitExceeded: + raise ValidationError(services.GENERIC_RATE_LIMITED) + return SendSignupOtp(success=True, message=message) + + +class RegisterWithPhone(graphene.Mutation): + """Create a phone + PIN account after proving the email with a code (W8). + + Phone is the canonical login identity; the email is proven by the code; the + PIN is the credential. Returns a session so the client goes straight into the + app, exactly like signInWithPhone. + + The prospective account is validated (parseable number, PIN policy, no + duplicate phone/email) BEFORE the single-use code is spent, so a weak PIN or an + already-registered number does not cost the caller their one-time code — the + rule reset_pin keeps for a bad new PIN. + """ + + token = graphene.String(description="JWT, as tokenAuth returns.") + user = graphene.Field(UserType) + + class Arguments: + phone = graphene.String(required=True, description="Any common format; normalised to E.164 server-side.") + email = graphene.String(required=True) + pin = graphene.String(required=True) + otp = graphene.String(required=True, description="The code emailed by sendSignupOtp.") + is_station_owner = graphene.Boolean(required=False, default_value=False) + + @public + def mutate(self, info, phone, email, pin, otp, is_station_owner=False): + # Cheap checks first so a predictable failure does not burn the code. + try: + services.validate_new_phone_account(phone, email, pin) + except services.CredentialError as e: + raise ValidationError(str(e)) + + try: + email_verified = email_service.verify_signup_otp(email, otp, request=info.context) + except ratelimit.RateLimitExceeded: + raise ValidationError(services.GENERIC_RATE_LIMITED) + + try: + user = services.create_phone_account( + phone, email_verified, pin, is_station_owner, request=info.context, + ) + except services.CredentialError as e: + raise ValidationError(str(e)) + + return RegisterWithPhone(token=get_token(user), user=user) + + +class SignInWithPhonePin(graphene.Mutation): + """The primary sign-in: phone + PIN in exchange for a session (W8). + + Generic and rate-limited (services.authenticate_phone_pin): an unknown number + and a wrong PIN are one message and one timing, so this cannot be walked as an + account-existence oracle. + """ + + token = graphene.String(description="JWT, as tokenAuth returns.") + user = graphene.Field(UserType) + + class Arguments: + phone = graphene.String(required=True) + pin = graphene.String(required=True) + + @public + def mutate(self, info, phone, pin): + try: + user = services.authenticate_phone_pin(phone, pin, request=info.context) + except services.CredentialError as e: + raise ValidationError(str(e)) + except ratelimit.RateLimitExceeded: + raise ValidationError(services.GENERIC_RATE_LIMITED) + return SignInWithPhonePin(token=get_token(user), user=user) + + +class SetMyPhone(graphene.Mutation): + """Attach a phone to the signed-in account, UNVERIFIED (W8). + + The step after a Google/Apple signup: the provider proved who they are, and we + then collect a phone for contact and booking. There is no SMS to prove it + (sms.py), so the number is stored as canonical-but-claimed — `phoneVerifiedAt` + stays NULL and no `phone` identity is written. This is deliberately NOT + linkPhone, which spends an OTP and DOES mark the number proven; conflating the + two would let an unverified number read as verified. + + Uniqueness is still enforced (the number is the login space), so two accounts + cannot claim it even unproven. + """ + + success = graphene.Boolean() + user = graphene.Field(UserType) + + class Arguments: + phone = graphene.String(required=True, description="Any common format; normalised to E.164 server-side.") + + @login_required + def mutate(self, info, phone): + user = identity.set_unverified_phone(user=info.context.user, phone_e164=phone) + log_event('phone_set_unverified', request=info.context, user=user) + return SetMyPhone(success=True, user=user) + + +class SignInWithGoogle(graphene.Mutation): + """Verify a Google ID token and return a session, creating the account if new. + + Mirrors signInWithPhone's shape: a provider identity we have seen returns to + its owner, one we have not becomes a new account (never an existing one matched + by email — see identity.sign_in_with_identity). `created` lets the client route + a first-time user to the phone-collection step (setMyPhone). + + The token is verified in accounts/social.py; whose account it is, is decided in + accounts/identity.py. This handler only wires the two together. + """ + + token = graphene.String(description="JWT, as tokenAuth returns.") + user = graphene.Field(UserType) + created = graphene.Boolean(description="True if this call registered a new account.") + + class Arguments: + id_token = graphene.String(required=True, description="The Google OIDC ID token from the client.") + + @public + def mutate(self, info, id_token): + subject, email = social.verify_google_id_token(id_token) + user, created = identity.sign_in_with_identity( + provider=AuthIdentity.PROVIDER_GOOGLE, subject=subject, email=email, + ) + log_event('login_success', request=info.context, user=user, method='google') + return SignInWithGoogle(token=get_token(user), user=user, created=created) + + +class SignInWithApple(graphene.Mutation): + """Verify an Apple identity token and return a session, creating if new. + + Same shape as signInWithGoogle. Apple sends an email only on the FIRST + authorisation, so `email` may be empty on later sign-ins — which is fine, the + account already exists by then, keyed on the provider subject. + """ + + token = graphene.String(description="JWT, as tokenAuth returns.") + user = graphene.Field(UserType) + created = graphene.Boolean(description="True if this call registered a new account.") + + class Arguments: + identity_token = graphene.String(required=True, description="The Apple OIDC identity token from the client.") + + @public + def mutate(self, info, identity_token): + subject, email = social.verify_apple_identity_token(identity_token) + user, created = identity.sign_in_with_identity( + provider=AuthIdentity.PROVIDER_APPLE, subject=subject, email=email, + ) + log_event('login_success', request=info.context, user=user, method='apple') + return SignInWithApple(token=get_token(user), user=user, created=created) + + class AccountsQuery(graphene.ObjectType): me = graphene.Field(UserType) auth_capabilities = graphene.Field( @@ -885,15 +1079,14 @@ def resolve_me(self, info): @public def resolve_auth_capabilities(self, info): return AuthCapabilitiesType( - # Real: reflects whether an SMS adapter is actually configured. - phone_sign_in=sms_configured(), - # Honest constants, not placeholders. There is no Google/Apple client - # ID, no token verification path, and nothing to toggle — so these - # report false rather than pretending to be configurable. W8 replaces - # them with a real check once credentials exist; until then a client - # that offers those buttons is offering nothing. - google_sign_in=False, - apple_sign_in=False, + # Phone + PIN needs no SMS and is the primary method (W8): always on. + phone_sign_in=True, + # Real checks now (W8): true exactly when the provider's client IDs are + # configured, so a client offers a button only when the token behind it + # can actually be verified. No credentials => false => no button. + google_sign_in=social.google_enabled(), + apple_sign_in=social.apple_enabled(), + # Legacy username/PIN sign-in still works for pre-W8 accounts. password_sign_in=True, ) @@ -909,6 +1102,14 @@ class AccountsMutation(graphene.ObjectType): unlink_provider = UnlinkProvider.Field() logout_everywhere = LogoutEverywhere.Field() + # Phone + PIN and social sign-in (W8). + send_signup_otp = SendSignupOtp.Field() + register_with_phone = RegisterWithPhone.Field() + sign_in_with_phone_pin = SignInWithPhonePin.Field() + set_my_phone = SetMyPhone.Field() + sign_in_with_google = SignInWithGoogle.Field() + sign_in_with_apple = SignInWithApple.Field() + # Canonical PIN mutations. send_pin_reset_otp = SendPinResetOtp.Field() reset_pin_with_otp = ResetPinWithOtp.Field() diff --git a/ev_backend/accounts/services.py b/ev_backend/accounts/services.py index f079708..765209f 100644 --- a/ev_backend/accounts/services.py +++ b/ev_backend/accounts/services.py @@ -9,17 +9,20 @@ constant-time OTP handling, and structured audit logging. """ +import hashlib import logging from django.contrib.auth import get_user_model, update_session_auth_hash from django.contrib.auth.hashers import check_password, make_password from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError +from django.db import transaction from django.utils import timezone from . import ratelimit from .auth_logging import log_event -from .models import PasswordResetOTP +from .models import AuthIdentity, PasswordResetOTP +from .phone import normalize_phone User = get_user_model() @@ -27,6 +30,9 @@ GENERIC_OTP_SENT = "If the email is registered, a reset code has been sent." GENERIC_OTP_INVALID = "Invalid or expired code." GENERIC_RATE_LIMITED = "Too many attempts. Please try again later." +# One message for "unknown number" and "wrong PIN" alike (W8), matching the +# wording tokenAuth already returns so the two login paths are indistinguishable. +GENERIC_INVALID_CREDENTIALS = "Please enter valid credentials" # Pre-computed hash used to keep verification timing constant when there is no # OTP (or user) for the supplied email — defeats timing-based enumeration. @@ -63,6 +69,134 @@ def create_account(username, email, pin, is_station_owner=False, request=None): return user +def _internal_username_for_phone(phone_e164): + """A stable, internal, non-guessable username for a phone-first account. + + Username stays required by ``AbstractUser`` but is no longer an identity a + person types — phone is (W8). It is derived from the number rather than + random so it is stable and debuggable, and HASHED rather than raw so the + number never lands in a field ``usersPage(search:)`` matches on. Same reasoning + as ``identity._generate_username``; kept separate because this account is not + created through the provider-identity path. + """ + digest = hashlib.sha256(f'phone:{phone_e164}'.encode()).hexdigest()[:16] + return f'phone_{digest}' + + +def validate_new_phone_account(phone, email, pin): + """Validate a prospective phone+PIN signup WITHOUT creating anything. + + Returns the normalised E.164 so a caller need not normalise twice. Split out + so ``registerWithPhone`` can run it BEFORE spending the single-use email OTP: + a weak PIN or an already-registered number must not cost the caller their + code — the same rule ``reset_pin`` keeps when it validates the new PIN before + burning a correct OTP. + + Uniqueness is ultimately a DB guarantee (``phone_e164`` is UNIQUE); these + checks exist to return a specific message instead of a caught IntegrityError + that cannot say which column collided. + """ + e164 = normalize_phone(phone) # raises ValidationError on an unparseable number + + try: + validate_password(pin) + except ValidationError as e: + raise CredentialError("; ".join(e.messages)) + + if User.objects.filter(phone_e164=e164).exists(): + raise CredentialError("That phone number is already registered.") + if User.objects.filter(email__iexact=email).exists(): + raise CredentialError("Email already registered") + return e164 + + +@transaction.atomic +def create_phone_account(phone, email, pin, is_station_owner=False, request=None): + """Register a phone-first account (W8). Phone is the canonical identity. + + Called only after ``email_service.verify_signup_otp`` has proven the email, + so the account starts with a PROVEN email and a CLAIMED-not-proven phone: no + SMS provider exists to prove the handset (sms.py), so ``phone_verified_at`` + stays NULL and no verified ``phone`` identity is created. The phone is still + canonical — it is UNIQUE and it is the login key — it is simply not marked + proven, because it was not. See docs/IDENTITY_ARCHITECTURE.md §9.2. + + The credential is a 6-digit PIN, hashed, exposed to the identity system as a + ``password`` provider exactly as the legacy login is (migration 0009), so the + last-identity and unlink rules treat a phone+PIN account uniformly. + """ + e164 = validate_new_phone_account(phone, email, pin) + + role = "station_owner" if is_station_owner else "user" + owner_status = User.OWNER_PENDING if is_station_owner else None + + username = _internal_username_for_phone(e164) + user = User.objects.create_user( + username=username, + email=email, + password=pin, # hashed by create_user; never plaintext + role=role, + owner_status=owner_status, + phone_e164=e164, # canonical, but unproven — see docstring + phone_verified_at=None, + ) + + # The PIN login, modelled as a provider so a phone+PIN account is one identity + # among several (mirrors migration 0009 for legacy accounts). verified_at is + # NULL: the OTP proved the email, not that this username belongs to a human. + AuthIdentity.objects.create( + user=user, + provider=AuthIdentity.PROVIDER_PASSWORD, + subject=username, + verified_at=None, + ) + + log_event("account_created", request=request, user=user, role=role, method="phone_pin") + return user + + +def authenticate_phone_pin(phone, pin, request=None): + """Return the user for a valid phone + PIN, else raise (W8). + + The primary sign-in path: phone is the canonical identity, the PIN is the + credential. Raises ``CredentialError`` for any authentication failure and + ``ratelimit.RateLimitExceeded`` when throttled — the caller translates both, + exactly as ``signInWithPhone`` does. + + Generic and timing-equalised: an unknown number and a wrong PIN return the + same message and take the same time (a dummy hash comparison runs when there + is no user), so this is not an oracle for "is this number registered". A phone + space is small and dense enough to walk, which is why the equalisation matters + more here than for email. Rate-limited per IP and per NORMALISED number, so + the three ways to write one number cannot each spend their own budget. + """ + ip = ratelimit.get_client_ip(request) + ratelimit.enforce("LOGIN", ip, "ip") + + try: + e164 = normalize_phone(phone) + except Exception: + # Unparseable: cannot match an account. Do not distinguish it from a wrong + # credential, but still burn time so the endpoint is not a parser oracle. + check_password(str(pin), _DUMMY_OTP_HASH) + log_event("login_failure", request=request, method="phone_pin") + raise CredentialError(GENERIC_INVALID_CREDENTIALS) + + ratelimit.enforce("LOGIN", e164, "account") + + user = User.objects.filter(phone_e164=e164).first() + if user is None or not user.check_password(pin): + if user is None: + check_password(str(pin), _DUMMY_OTP_HASH) # equalise timing + log_event("login_failure", request=request, method="phone_pin") + raise CredentialError(GENERIC_INVALID_CREDENTIALS) + + ratelimit.reset("LOGIN", ip, "ip") + ratelimit.reset("LOGIN", e164, "account") + log_event("login_success", request=request, user=user, method="phone_pin") + return user + + def change_pin(user, current_pin, new_pin, request=None): """Authenticated PIN change. Returns ``(success, message)``.""" if not user.check_password(current_pin): diff --git a/ev_backend/accounts/social.py b/ev_backend/accounts/social.py new file mode 100644 index 0000000..93164eb --- /dev/null +++ b/ev_backend/accounts/social.py @@ -0,0 +1,154 @@ +"""Social sign-in token verification (Sprint W8). + +The ONE place that turns a provider's ID token into a verified ``(subject, +email)``. Everything downstream — ``identity.sign_in_with_identity`` — takes it +from there and never touches a token or the network. This is to Google/Apple what +``phone_service`` is to SMS and ``email_service`` is to email: the boundary where +an external proof becomes an internal fact, isolated so the rules that decide +whose account it is stay free of HTTP and crypto. + +Google and Apple both issue OpenID Connect ID tokens: RS256 JWTs signed with keys +published at a JWKS endpoint. Verifying one is three checks — fetch the signing +key named by the token's ``kid``, check the RS256 signature, and assert issuer, +audience and expiry. PyJWT's ``PyJWKClient`` does the key fetch and cache; +``jwt.decode`` does the signature and claim checks. No provider SDK, so no new +dependency. + +AUDIENCE IS THE CONTROL THAT MATTERS. A validly-signed Google token minted for a +DIFFERENT application is still a genuine Google token — its signature verifies. +Pinning ``aud`` to THIS deployment's own client IDs is what stops such a token +being replayed against us. With no client IDs configured a provider is simply not +enabled: verification refuses loudly (never silently accepts), and +``authCapabilities`` reports it ``false`` so no client offers the button — the +same honesty rule sms.py keeps. +""" + +import logging + +import jwt +from jwt import PyJWKClient +from django.conf import settings + +from ev_backend.errors import APIError + +from .models import AuthIdentity + +logger = logging.getLogger('accounts.social') + +GOOGLE_ISSUERS = {'https://accounts.google.com', 'accounts.google.com'} +GOOGLE_JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs' + +APPLE_ISSUER = 'https://appleid.apple.com' +APPLE_JWKS_URL = 'https://appleid.apple.com/auth/keys' + +# JWKS clients cache keys internally, so build them once at import rather than per +# request; a per-call client would refetch Google's key set on every sign-in. +_GOOGLE_JWKS = PyJWKClient(GOOGLE_JWKS_URL) +_APPLE_JWKS = PyJWKClient(APPLE_JWKS_URL) + + +class SocialAuthUnavailable(APIError): + """The provider is not configured, so its token cannot be verified. + + A typed failure, like ``SmsUnavailable``: the client needs to tell "this + deployment does not support Google" (do not offer the button) apart from + "your Google token did not verify" (transient, retry). Surfaces as + ``extensions.code = 'social_auth_unavailable'``. + """ + + code = 'social_auth_unavailable' + + +class SocialAuthError(APIError): + """The token did not verify — bad signature, wrong audience, expired, forged. + + One message for every cause, for the same reason the OTP paths give one: the + distinctions are useful to an attacker and useless to a user, who retries the + sign-in regardless. The real reason is logged server-side, never returned. + """ + + code = 'social_auth_failed' + + +def _configured_ids(setting_name): + """The audience allow-list for a provider, or an empty tuple if disabled.""" + return tuple(getattr(settings, setting_name, ()) or ()) + + +def google_enabled(): + return bool(_configured_ids('GOOGLE_OAUTH_CLIENT_IDS')) + + +def apple_enabled(): + return bool(_configured_ids('APPLE_CLIENT_IDS')) + + +def _verify(id_token, *, jwks_client, issuers, audiences, provider): + """Verify an OIDC ID token and return its validated claims. + + Decodes WITHOUT jwt.decode's own issuer check because Google publishes two + acceptable issuer strings and that option takes exactly one; the issuer is + asserted against the set below instead. Audience and expiry are enforced by + jwt.decode. + """ + if not audiences: + raise SocialAuthUnavailable( + f"{provider.title()} sign-in is not available on this deployment." + ) + + try: + signing_key = jwks_client.get_signing_key_from_jwt(id_token) + claims = jwt.decode( + id_token, + signing_key.key, + algorithms=['RS256'], + audience=list(audiences), + options={'require': ['exp', 'iat', 'sub']}, + ) + except (jwt.PyJWTError, jwt.PyJWKClientError) as exc: + # The operator needs the cause to read an incident; the caller does not. + logger.warning('%s token verification failed: %s', provider, exc) + raise SocialAuthError("Could not verify that sign-in. Please try again.") + except Exception as exc: # JWKS fetch / network — our problem, not the user's + logger.error('%s JWKS/verification error: %s', provider, exc) + raise SocialAuthError("Could not verify that sign-in. Please try again.") + + if claims.get('iss') not in issuers: + logger.warning('%s token has unexpected issuer %r', provider, claims.get('iss')) + raise SocialAuthError("Could not verify that sign-in. Please try again.") + + return claims + + +def verify_google_id_token(id_token): + """Verify a Google ID token. Returns ``(subject, email)``. + + ``subject`` is Google's ``sub`` — the stable, opaque per-user ID that becomes + ``AuthIdentity.subject``. ``email`` is for record-keeping only and is never + used to match an existing account (see identity.sign_in_with_identity). + """ + claims = _verify( + id_token, + jwks_client=_GOOGLE_JWKS, + issuers=GOOGLE_ISSUERS, + audiences=_configured_ids('GOOGLE_OAUTH_CLIENT_IDS'), + provider=AuthIdentity.PROVIDER_GOOGLE, + ) + return claims['sub'], claims.get('email') or '' + + +def verify_apple_identity_token(id_token): + """Verify an Apple identity token. Returns ``(subject, email)``. + + Apple returns ``email`` only on the FIRST authorisation (and only if the app + requested the scope); later tokens omit it. An empty email is therefore normal + and not an error — the account already exists by then, keyed on ``sub``. + """ + claims = _verify( + id_token, + jwks_client=_APPLE_JWKS, + issuers={APPLE_ISSUER}, + audiences=_configured_ids('APPLE_CLIENT_IDS'), + provider=AuthIdentity.PROVIDER_APPLE, + ) + return claims['sub'], claims.get('email') or '' diff --git a/ev_backend/accounts/templates/accounts/emails/signup_otp.html b/ev_backend/accounts/templates/accounts/emails/signup_otp.html new file mode 100644 index 0000000..a8b59af --- /dev/null +++ b/ev_backend/accounts/templates/accounts/emails/signup_otp.html @@ -0,0 +1,69 @@ + + + + + + + +
+

⚡ Welcome to EV Charge Hub

+
+
+

Use the following code to finish creating your account:

+ +
{{ otp }}
+ +

This code is valid for {{ minutes }} minutes. We will never ask you for it.

+ +
+ ⚠️ Security Notice: If you did not try to create an account, + you can safely ignore this email. +
+
+ + + diff --git a/ev_backend/accounts/templates/accounts/emails/signup_otp.txt b/ev_backend/accounts/templates/accounts/emails/signup_otp.txt new file mode 100644 index 0000000..eb496d1 --- /dev/null +++ b/ev_backend/accounts/templates/accounts/emails/signup_otp.txt @@ -0,0 +1,13 @@ +Welcome to EV Charge Hub! + +Use the following code to finish creating your account: + +Code: {{ otp }} + +This code is valid for {{ minutes }} minutes. We will never ask you for it. + +If you did not try to create an account, you can safely ignore this email. + +— EV Charge Hub Team + +This is an automated message, please do not reply to this email. diff --git a/ev_backend/accounts/test_b1_authorization_policy.py b/ev_backend/accounts/test_b1_authorization_policy.py index 290dc5d..a37f510 100644 --- a/ev_backend/accounts/test_b1_authorization_policy.py +++ b/ev_backend/accounts/test_b1_authorization_policy.py @@ -94,6 +94,18 @@ 'linkPhone': POLICY_AUTHENTICATED, 'unlinkProvider': POLICY_AUTHENTICATED, 'logoutEverywhere': POLICY_AUTHENTICATED, + # Phone + PIN and social sign-in (W8). The sign-in / signup entry points are + # public for the same reason tokenAuth is — the caller has no session yet — + # and are hardened by rate limiting, single-use emailed OTPs, generic + # timing-equalised replies (phone+PIN), and provider token verification with + # an audience allow-list (Google/Apple). setMyPhone is the one signed-in + # member: it writes to your own account. + 'sendSignupOtp': POLICY_PUBLIC, + 'registerWithPhone': POLICY_PUBLIC, + 'signInWithPhonePin': POLICY_PUBLIC, + 'signInWithGoogle': POLICY_PUBLIC, + 'signInWithApple': POLICY_PUBLIC, + 'setMyPhone': POLICY_AUTHENTICATED, # Active account required for anything that writes domain data. 'createBooking': POLICY_ACTIVE, 'cancelBooking': POLICY_ACTIVE, diff --git a/ev_backend/accounts/test_w7_identity.py b/ev_backend/accounts/test_w7_identity.py index b0b79ca..8164963 100644 --- a/ev_backend/accounts/test_w7_identity.py +++ b/ev_backend/accounts/test_w7_identity.py @@ -596,10 +596,14 @@ def test_it_refuses_out_loud_instead_of_dropping_the_code(self): with self.assertRaises(SmsUnavailable): phone_service.send_phone_otp('0911223344', request=Context()) - def test_the_api_reports_phone_sign_in_as_unavailable(self): + def test_phone_pin_sign_in_stays_available_without_an_sms_provider(self): + # W8 redefined `phoneSignIn`: it now reports phone+PIN (the primary + # method), which needs no SMS. So it is TRUE even here, where SMS is + # disabled — what this deployment lacks is the passwordless phone-OTP path, + # not phone sign-in itself. result = schema.execute('{ authCapabilities { phoneSignIn } }', context=Context()) self.assertIsNone(result.errors) - self.assertFalse(result.data['authCapabilities']['phoneSignIn']) + self.assertTrue(result.data['authCapabilities']['phoneSignIn']) class AuthCapabilitiesTellTheTruth(TestCase): @@ -615,8 +619,11 @@ def test_google_and_apple_report_unavailable_because_they_are(self): self.assertTrue(result.data['authCapabilities']['passwordSignIn']) @override_settings(SMS_BACKEND='console') - def test_phone_availability_follows_real_configuration(self): - # Not a constant: it moves when the deployment moves. + def test_phone_sign_in_no_longer_depends_on_the_sms_backend(self): + # W8: `phoneSignIn` reports phone+PIN availability, independent of the SMS + # backend — true with console configured, and (see PhoneFlowWithNoProvider) + # true with no provider at all. Google/Apple are the capabilities that now + # move with configuration. result = schema.execute('{ authCapabilities { phoneSignIn } }', context=Context()) self.assertTrue(result.data['authCapabilities']['phoneSignIn']) diff --git a/ev_backend/accounts/test_w8_auth.py b/ev_backend/accounts/test_w8_auth.py new file mode 100644 index 0000000..d98772a --- /dev/null +++ b/ev_backend/accounts/test_w8_auth.py @@ -0,0 +1,372 @@ +"""Phone + PIN and social sign-in (Sprint W8). + +The three sign-in options a client now offers — phone+PIN, Google, Apple — and +the one signup path that feeds them, executable. Written under the same rule as +the W7 suite ("a guard you have not seen fail is not a guard"): every assertion +here was watched failing before it was allowed to pass. + +The single most important property, tested from several angles: an account +created through the emailed-OTP signup has a PROVEN EMAIL and a CLAIMED-not-proven +PHONE. There is no SMS provider, so the phone cannot be proven, and pretending it +was would be a lie the identity-linking rules later trust. See +docs/IDENTITY_ARCHITECTURE.md §9.2, EmailVerification, and identity.py. +""" + +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings + +from ev_backend.schema import schema + +from . import email_service, identity, services, social +from .models import AuthIdentity, EmailVerification + +User = get_user_model() + + +class Context: + """A request stand-in. Rate limiting reads META; resolvers read `user`.""" + + def __init__(self, user=None, ip='198.51.100.9'): + self.user = user + self.META = {'REMOTE_ADDR': ip} + + +def clear_rate_limits(): + from django.core.cache import cache + + cache.clear() + + +def issue_signup_code(email, ip='198.51.100.9'): + """Send a signup code and return the plaintext, by capturing the value the + delivery step is handed. Mirrors how the W7 suite reads the SMS code: never + from the database (only the hash is stored), always from delivery. + """ + captured = {} + + def capture(self, code): # replaces EmailVerification._send_email + captured['code'] = code + + with patch.object(EmailVerification, '_send_email', capture): + email_service.send_signup_otp(email, request=Context(ip=ip)) + return captured['code'] + + +def register(*, phone, email, pin, otp, owner=False, ip='198.51.100.9'): + return schema.execute( + ''' + mutation ($phone: String!, $email: String!, $pin: String!, $otp: String!, $owner: Boolean) { + registerWithPhone(phone: $phone, email: $email, pin: $pin, otp: $otp, isStationOwner: $owner) { + token + user { id email phoneE164 phoneVerifiedAt role ownerStatus linkedProviders { provider verified } } + } + } + ''', + variables={'phone': phone, 'email': email, 'pin': pin, 'otp': otp, 'owner': owner}, + context=Context(ip=ip), + ) + + +# ── Signup: emailed OTP, phone + PIN ───────────────────────────────────────── + + +class PhonePinSignup(TestCase): + def setUp(self): + clear_rate_limits() + + def test_a_full_signup_creates_an_account_and_returns_a_session(self): + code = issue_signup_code('driver@example.com') + result = register( + phone='0911223344', email='driver@example.com', pin='481902', otp=code, + ) + self.assertIsNone(result.errors) + data = result.data['registerWithPhone'] + self.assertTrue(data['token']) + self.assertEqual(data['user']['email'], 'driver@example.com') + self.assertEqual(data['user']['phoneE164'], '+251911223344') # normalised + self.assertEqual(data['user']['role'], 'user') + + def test_the_phone_is_canonical_but_NOT_marked_verified(self): + # The property this whole sprint turns on: an emailed code proves the + # mailbox, not the handset. A verified phone here would be a lie. + code = issue_signup_code('claim@example.com') + register(phone='0911223345', email='claim@example.com', pin='481902', otp=code) + + user = User.objects.get(email='claim@example.com') + self.assertEqual(user.phone_e164, '+251911223345') # canonical + self.assertIsNone(user.phone_verified_at) # but not proven + self.assertFalse(user.has_verified_phone()) + # ...and no verified `phone` identity was written. + self.assertFalse( + AuthIdentity.objects.filter(user=user, provider=AuthIdentity.PROVIDER_PHONE).exists() + ) + + def test_the_pin_is_stored_as_a_password_identity_hashed(self): + code = issue_signup_code('pin@example.com') + register(phone='0911223346', email='pin@example.com', pin='481902', otp=code) + + user = User.objects.get(email='pin@example.com') + self.assertTrue(user.check_password('481902')) # hashed, verifiable + self.assertNotIn('481902', user.password) # never plaintext + self.assertTrue( + AuthIdentity.objects.filter(user=user, provider=AuthIdentity.PROVIDER_PASSWORD).exists() + ) + + def test_a_wrong_code_is_refused_and_no_account_appears(self): + issue_signup_code('nope@example.com') + result = register(phone='0911223347', email='nope@example.com', pin='481902', otp='000000') + self.assertIsNotNone(result.errors) + self.assertFalse(User.objects.filter(email='nope@example.com').exists()) + + def test_a_predictable_failure_does_not_burn_the_single_use_code(self): + # A weak PIN is caught BEFORE the code is spent, so the caller can retry + # with the SAME code — the rule reset_pin keeps for a bad new PIN. + code = issue_signup_code('retry@example.com') + bad = register(phone='0911223348', email='retry@example.com', pin='12', otp=code) + self.assertIsNotNone(bad.errors) + + good = register(phone='0911223348', email='retry@example.com', pin='481902', otp=code) + self.assertIsNone(good.errors) + self.assertTrue(good.data['registerWithPhone']['token']) + + def test_a_duplicate_phone_is_refused(self): + code = issue_signup_code('first@example.com') + register(phone='0911223349', email='first@example.com', pin='481902', otp=code) + + code2 = issue_signup_code('second@example.com') + dup = register(phone='+251911223349', email='second@example.com', pin='481902', otp=code2) + self.assertIsNotNone(dup.errors) + self.assertIn('phone', dup.errors[0].message.lower()) + + def test_a_duplicate_email_is_refused(self): + User.objects.create_user(username='u_dup', email='taken@example.com', password='481902') + code = issue_signup_code('taken@example.com') + dup = register(phone='0911777788', email='taken@example.com', pin='481902', otp=code) + self.assertIsNotNone(dup.errors) + + def test_a_pin_that_is_not_six_digits_is_refused(self): + code = issue_signup_code('weak@example.com') + result = register(phone='0911223350', email='weak@example.com', pin='abcd', otp=code) + self.assertIsNotNone(result.errors) + + def test_registering_as_a_station_owner_starts_pending(self): + code = issue_signup_code('owner@example.com') + result = register( + phone='0911223351', email='owner@example.com', pin='481902', otp=code, owner=True, + ) + self.assertIsNone(result.errors) + user = User.objects.get(email='owner@example.com') + self.assertEqual(user.role, 'station_owner') + self.assertEqual(user.owner_status, User.OWNER_PENDING) + + def test_the_code_is_actually_emailed_and_says_what_it_is_for(self): + from django.core import mail + + with override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend'): + email_service.send_signup_otp('mailed@example.com', request=Context()) + self.assertEqual(len(mail.outbox), 1) + self.assertIn('mailed@example.com', mail.outbox[0].to) + self.assertIn('EV Charge Hub', mail.outbox[0].body) + + +# ── Phone + PIN sign-in ────────────────────────────────────────────────────── + + +class PhonePinSignIn(TestCase): + def setUp(self): + clear_rate_limits() + code = issue_signup_code('login@example.com') + register(phone='0911223360', email='login@example.com', pin='481902', otp=code) + clear_rate_limits() + + def _sign_in(self, phone, pin, ip='203.0.113.5'): + return schema.execute( + ''' + mutation ($phone: String!, $pin: String!) { + signInWithPhonePin(phone: $phone, pin: $pin) { token user { email } } + } + ''', + variables={'phone': phone, 'pin': pin}, + context=Context(ip=ip), + ) + + def test_correct_phone_and_pin_returns_a_session(self): + result = self._sign_in('0911223360', '481902') + self.assertIsNone(result.errors) + self.assertTrue(result.data['signInWithPhonePin']['token']) + self.assertEqual(result.data['signInWithPhonePin']['user']['email'], 'login@example.com') + + def test_any_format_of_the_registered_number_signs_in(self): + # Registered as 0911...; signing in with the +251... form must resolve to + # the same account — the reason phone.py exists. + result = self._sign_in('+251911223360', '481902') + self.assertIsNone(result.errors) + self.assertTrue(result.data['signInWithPhonePin']['token']) + + def test_a_wrong_pin_is_refused_generically(self): + result = self._sign_in('0911223360', '000000') + self.assertIsNotNone(result.errors) + self.assertIsNone(result.data['signInWithPhonePin']) + + def test_an_unknown_number_gives_the_same_answer_as_a_wrong_pin(self): + wrong_pin = self._sign_in('0911223360', '000000', ip='203.0.113.6') + unknown = self._sign_in('0912000000', '481902', ip='203.0.113.7') + self.assertIsNotNone(wrong_pin.errors) + self.assertIsNotNone(unknown.errors) + # Indistinguishable: same message for "wrong PIN" and "no such number". + self.assertEqual(wrong_pin.errors[0].message, unknown.errors[0].message) + + +# ── setMyPhone: the phone a social signup collects, UNVERIFIED ──────────────── + + +class SetMyPhoneCollectsUnverified(TestCase): + def setUp(self): + clear_rate_limits() + self.user = User.objects.create_user( + username='google_abc', email='social@example.com', password=None, + ) + + def _set_phone(self, user, phone): + return schema.execute( + 'mutation ($p: String!) { setMyPhone(phone: $p) { success user { phoneE164 phoneVerifiedAt } } }', + variables={'p': phone}, + context=Context(user=user), + ) + + def test_it_stores_the_number_as_canonical_but_unproven(self): + result = self._set_phone(self.user, '0911445566') + self.assertIsNone(result.errors) + self.assertEqual(result.data['setMyPhone']['user']['phoneE164'], '+251911445566') + self.assertIsNone(result.data['setMyPhone']['user']['phoneVerifiedAt']) + + self.user.refresh_from_db() + self.assertIsNone(self.user.phone_verified_at) + # No `phone` identity — an identity row would assert a proof that never + # happened. Contrast linkPhone, which spends an OTP and DOES write one. + self.assertFalse( + AuthIdentity.objects.filter(user=self.user, provider=AuthIdentity.PROVIDER_PHONE).exists() + ) + + def test_it_refuses_a_number_another_account_already_holds(self): + other = User.objects.create_user(username='other', email='o@example.com', password='481902') + identity.set_unverified_phone(user=other, phone_e164='0911445577') + + result = self._set_phone(self.user, '+251911445577') + self.assertIsNotNone(result.errors) + + def test_it_requires_a_session(self): + result = self._set_phone(Context().user, '0911445588') # user=None + self.assertIsNotNone(result.errors) + + +# ── Google / Apple sign-in ─────────────────────────────────────────────────── + + +@override_settings(GOOGLE_OAUTH_CLIENT_IDS=['web.apps.googleusercontent.com']) +class GoogleSignIn(TestCase): + def setUp(self): + clear_rate_limits() + + def _sign_in(self, id_token='tok'): + return schema.execute( + ''' + mutation ($t: String!) { + signInWithGoogle(idToken: $t) { token created user { email } } + } + ''', + variables={'t': id_token}, + context=Context(), + ) + + def test_a_verified_token_creates_an_account_the_first_time(self): + with patch.object(social, 'verify_google_id_token', return_value=('g-sub-1', 'gmail@example.com')): + result = self._sign_in() + self.assertIsNone(result.errors) + self.assertTrue(result.data['signInWithGoogle']['created']) + self.assertTrue(result.data['signInWithGoogle']['token']) + self.assertTrue( + AuthIdentity.objects.filter(provider=AuthIdentity.PROVIDER_GOOGLE, subject='g-sub-1').exists() + ) + + def test_the_second_sign_in_returns_the_same_account(self): + with patch.object(social, 'verify_google_id_token', return_value=('g-sub-2', 'again@example.com')): + first = self._sign_in() + second = self._sign_in() + self.assertTrue(first.data['signInWithGoogle']['created']) + self.assertFalse(second.data['signInWithGoogle']['created']) + self.assertEqual(User.objects.filter(identities__subject='g-sub-2').count(), 1) + + def test_email_is_never_used_to_hijack_an_existing_account(self): + # The anti-takeover rule: a pre-existing account with the same email must + # NOT be adopted by a Google sign-in. Our stored emails were never proven. + victim = User.objects.create_user( + username='victim', email='shared@example.com', password='481902', + ) + with patch.object(social, 'verify_google_id_token', return_value=('g-sub-3', 'shared@example.com')): + result = self._sign_in() + self.assertIsNone(result.errors) + new_user_id = User.objects.get(identities__subject='g-sub-3').id + self.assertNotEqual(new_user_id, victim.id) # a NEW account, not the victim's + + def test_a_new_google_user_has_no_phone_until_they_set_one(self): + with patch.object(social, 'verify_google_id_token', return_value=('g-sub-4', 'n@example.com')): + self._sign_in() + user = User.objects.get(identities__subject='g-sub-4') + self.assertIsNone(user.phone_e164) # the client routes them to setMyPhone + + +class AppleSignIn(TestCase): + def setUp(self): + clear_rate_limits() + + @override_settings(APPLE_CLIENT_IDS=['com.evchargehub.app']) + def test_apple_signup_works_even_when_the_token_carries_no_email(self): + # Apple omits the email on all but the first authorisation; an empty email + # is normal, not an error. + with patch.object(social, 'verify_apple_identity_token', return_value=('a-sub-1', '')): + result = schema.execute( + 'mutation ($t: String!) { signInWithApple(identityToken: $t) { token created } }', + variables={'t': 'tok'}, + context=Context(), + ) + self.assertIsNone(result.errors) + self.assertTrue(result.data['signInWithApple']['created']) + self.assertTrue(result.data['signInWithApple']['token']) + + +# ── Capabilities honestly reflect configuration ────────────────────────────── + + +class AuthCapabilitiesReflectConfiguration(TestCase): + def _caps(self): + result = schema.execute( + '{ authCapabilities { phoneSignIn googleSignIn appleSignIn passwordSignIn } }', + context=Context(), + ) + self.assertIsNone(result.errors) + return result.data['authCapabilities'] + + def test_phone_and_password_are_always_available(self): + caps = self._caps() + self.assertTrue(caps['phoneSignIn']) + self.assertTrue(caps['passwordSignIn']) + + def test_google_and_apple_are_false_without_client_ids(self): + caps = self._caps() + self.assertFalse(caps['googleSignIn']) + self.assertFalse(caps['appleSignIn']) + + @override_settings(GOOGLE_OAUTH_CLIENT_IDS=['x'], APPLE_CLIENT_IDS=['y']) + def test_google_and_apple_turn_on_when_configured(self): + caps = self._caps() + self.assertTrue(caps['googleSignIn']) + self.assertTrue(caps['appleSignIn']) + + def test_an_unconfigured_provider_refuses_verification(self): + # With no client IDs, social verification must refuse loudly rather than + # accept anything — the honesty rule from sms.py, applied to tokens. + with self.assertRaises(social.SocialAuthUnavailable): + social.verify_google_id_token('anything') diff --git a/ev_backend/ev_backend/settings.py b/ev_backend/ev_backend/settings.py index 729a49e..a302170 100644 --- a/ev_backend/ev_backend/settings.py +++ b/ev_backend/ev_backend/settings.py @@ -281,6 +281,17 @@ # no real provider yet; see accounts/sms.py and IDENTITY_ARCHITECTURE.md §9.2. SMS_BACKEND = config('SMS_BACKEND', default='disabled') +# Social sign-in (W8). Comma-separated OAuth client IDs that a token's `aud` must +# match — the anti-forgery control in accounts/social.py. Empty (the default) +# means the provider is not configured: verification refuses and authCapabilities +# reports it false, so no client offers the button. A deployment enables Google +# or Apple purely by setting these; nothing else toggles. +# +# Google typically needs one ID per client platform (web, Android, iOS) — list +# all of them. Apple's is the Services ID (web) and/or the app's bundle ID. +GOOGLE_OAUTH_CLIENT_IDS = config('GOOGLE_OAUTH_CLIENT_IDS', default='', cast=Csv()) +APPLE_CLIENT_IDS = config('APPLE_CLIENT_IDS', default='', cast=Csv()) + # Structured authentication logging (Part 5). The accounts.auth_logging helper # scrubs sensitive fields, so PINs/OTPs/tokens are never written here. # All handlers write to stdout, which is log-rotation friendly (the container/ diff --git a/ev_backend/requirements.txt b/ev_backend/requirements.txt index beb6883..7b43d86 100644 --- a/ev_backend/requirements.txt +++ b/ev_backend/requirements.txt @@ -14,6 +14,9 @@ graphql-relay==3.2.0 # --- Auth / utils --- PyJWT==2.10.1 +# Required by PyJWT to verify RS256 Google/Apple ID tokens (W8 social sign-in, +# accounts/social.py; PyJWKClient fetches each provider's JWKS). +cryptography==49.0.0 python-decouple==3.8 promise==2.3 python-dateutil==2.9.0.post0