From 3ce8dedd5aa953cf64978062dddafffcfb38183b Mon Sep 17 00:00:00 2001 From: hackerman70000 Date: Tue, 28 Jan 2025 00:37:56 +0100 Subject: [PATCH] feat: implement email verification process; update user model and add verification email functionality --- backend/app/__init__.py | 3 +- backend/app/models.py | 16 +- backend/app/routes/auth.py | 158 ++++++++++++++-- backend/app/templates/email/verify_email.html | 43 +++++ backend/app/utils.py | 54 +++++- backend/config.py | 2 + .../017a7e9a4716_add_email_verification.py | 48 +++++ docker-compose.yml | 2 +- frontend/app/(auth)/_layout.jsx | 33 ++-- frontend/app/(auth)/sign-in.jsx | 32 +++- frontend/app/(auth)/sign-up.jsx | 11 +- frontend/app/(auth)/verify-email.jsx | 169 ++++++++++++++++++ nginx.conf | 4 + 13 files changed, 524 insertions(+), 51 deletions(-) create mode 100644 backend/app/templates/email/verify_email.html create mode 100644 backend/migrations/versions/017a7e9a4716_add_email_verification.py create mode 100644 frontend/app/(auth)/verify-email.jsx diff --git a/backend/app/__init__.py b/backend/app/__init__.py index ba4ba1f..77f4413 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -8,11 +8,12 @@ db = SQLAlchemy() migrate = Migrate() +CORS_ORIGIN_WHITELIST = ["http://localhost"] def create_app(config_class=Config): app = Flask(__name__, static_url_path="/static", static_folder="static") - CORS(app) + CORS(app, resources={r"/api/*": {"origins": "http://localhost"}}) app.config.from_object(config_class) # Initialize database diff --git a/backend/app/models.py b/backend/app/models.py index 36ce697..439fbe2 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,4 +1,5 @@ -from datetime import datetime +import secrets +from datetime import datetime, timedelta, timezone from app import db @@ -8,8 +9,17 @@ class User(db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False) - password = db.Column(db.String(256), nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + password = db.Column(db.String(255), nullable=False) + email_verified = db.Column(db.Boolean, default=False) + verification_token = db.Column(db.String(100), unique=True) + verification_token_expires = db.Column(db.DateTime(timezone=True)) + + def generate_verification_token(self): + self.verification_token = secrets.token_urlsafe(32) + self.verification_token_expires = datetime.now(timezone.utc) + timedelta( + hours=24 + ) + return self.verification_token class Product(db.Model): diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 427ae68..4508638 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -1,14 +1,15 @@ import re -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from functools import wraps +from json import loads from config import Config -from pyseto import Key, Paseto, VerifyError, DecryptError from flask import Blueprint, jsonify, request +from pyseto import DecryptError, Key, Paseto, VerifyError from werkzeug.security import check_password_hash, generate_password_hash -from json import loads from app.models import User, db +from app.utils import send_verification_email auth_bp = Blueprint("auth", __name__) @@ -180,25 +181,142 @@ def register(): ), 409 hashed_password = generate_password_hash(password) - new_user = User(username=username, email=email, password=hashed_password) + new_user = User( + username=username, + email=email, + password=hashed_password, + email_verified=False, + ) + + new_user.generate_verification_token() db.session.add(new_user) db.session.commit() + if send_verification_email(new_user): + return jsonify( + { + "message": "Registration successful", + "details": "Please check your email to verify your account", + "user": { + "id": new_user.id, + "username": new_user.username, + "email": new_user.email, + "email_verified": new_user.email_verified, + }, + } + ), 201 + else: + return jsonify( + { + "message": "Registration successful but verification email failed", + "details": "Please contact support to verify your email", + } + ), 201 + + except Exception as e: + db.session.rollback() + return jsonify({"message": "Registration failed", "details": str(e)}), 500 + + +@auth_bp.route("/verify-email/", methods=["GET"]) +def verify_email(token): + try: + user = User.query.filter_by(verification_token=token).first() + + if not user: + return jsonify( + { + "message": "Invalid verification token", + "details": "Token not found or already used", + } + ), 404 + + if user.email_verified: + return jsonify( + { + "message": "Email already verified", + "details": "Your email has already been verified", + } + ), 400 + + if user.verification_token_expires < datetime.now(timezone.utc): + return jsonify( + { + "message": "Verification token expired", + "details": "Please request a new verification email", + } + ), 400 + + user.email_verified = True + user.verification_token = None + user.verification_token_expires = None + db.session.commit() + return jsonify( { - "message": "Registration successful", - "user": { - "id": new_user.id, - "username": new_user.username, - "email": new_user.email, - }, + "message": "Email verified successfully", + "details": "You can now log in to your account", } - ), 201 + ) except Exception as e: db.session.rollback() - return jsonify({"message": "Registration failed", "details": str(e)}), 500 + return jsonify({"message": "Email verification failed", "details": str(e)}), 500 + + +@auth_bp.route("/resend-verification", methods=["POST"]) +def resend_verification(): + try: + data = request.get_json() + + if not data or "email" not in data: + return jsonify( + {"message": "Invalid request", "details": "Email is required"} + ), 400 + + email = str(data["email"]).strip().lower() + user = User.query.filter_by(email=email).first() + + if not user: + return jsonify( + { + "message": "User not found", + "details": "No account found with this email", + } + ), 404 + + if user.email_verified: + return jsonify( + { + "message": "Email already verified", + "details": "Your email has already been verified", + } + ), 400 + + user.generate_verification_token() + db.session.commit() + + if send_verification_email(user): + return jsonify( + { + "message": "Verification email sent", + "details": "Please check your email to verify your account", + } + ) + else: + return jsonify( + { + "message": "Failed to send verification email", + "details": "Please try again later", + } + ), 500 + + except Exception as e: + db.session.rollback() + return jsonify( + {"message": "Failed to resend verification email", "details": str(e)} + ), 500 @auth_bp.route("/login", methods=["POST"]) @@ -220,6 +338,14 @@ def login(): {"message": "Authentication failed", "details": "User not found"} ), 401 + if not user.email_verified: + return jsonify( + { + "message": "Email not verified", + "details": "Please verify your email before logging in", + } + ), 401 + if not check_password_hash(user.password, password): return jsonify( {"message": "Authentication failed", "details": "Invalid password"} @@ -236,7 +362,12 @@ def login(): { "message": "Login successful", "token": token.decode(), - "user": {"id": user.id, "username": user.username, "email": user.email}, + "user": { + "id": user.id, + "username": user.username, + "email": user.email, + "email_verified": user.email_verified, + }, } ) @@ -253,6 +384,7 @@ def get_current_user(current_user): "id": current_user.id, "username": current_user.username, "email": current_user.email, + "email_verified": current_user.email_verified, } ) except Exception as e: diff --git a/backend/app/templates/email/verify_email.html b/backend/app/templates/email/verify_email.html new file mode 100644 index 0000000..bfbe217 --- /dev/null +++ b/backend/app/templates/email/verify_email.html @@ -0,0 +1,43 @@ + + + + + + +
+
+

Verify Your Email

+
+ +

Hello {{ username }},

+

Thank you for registering. Please click the button below to verify your email address:

+ +
+ Verify Email +
+ +

Or copy and paste this link in your browser:

+

{{ verification_url }}

+ +

This link will expire in 24 hours.

+ + +
+ + \ No newline at end of file diff --git a/backend/app/utils.py b/backend/app/utils.py index 0f4b382..d5d0456 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -1,9 +1,9 @@ -from flask import render_template +from flask import current_app, render_template from flask_mail import Mail, Message -from flask import current_app mail = Mail() + def send_order_confirmation_email(order): """Send order confirmation email with receipt to customer""" try: @@ -14,10 +14,10 @@ def send_order_confirmation_email(order): ) msg.html = render_template( - "email/order_confirmation.html", - order=order, - user=order.user, - items=order.items + "email/order_confirmation.html", + order=order, + user=order.user, + items=order.items, ) items_text = "\n".join( @@ -33,7 +33,7 @@ def send_order_confirmation_email(order): Thank you for your order! Order Details: - Date: {order.created_at.strftime('%Y-%m-%d %H:%M:%S')} + Date: {order.created_at.strftime("%Y-%m-%d %H:%M:%S")} Items: {items_text} @@ -47,4 +47,42 @@ def send_order_confirmation_email(order): return True except Exception as e: current_app.logger.error(f"Failed to send order confirmation email: {str(e)}") - return False \ No newline at end of file + return False + + +def send_verification_email(user): + """Send email verification link to user""" + try: + current_app.logger.info(f"Attempting to send email to: {user.email}") + + verification_url = f"{current_app.config['FRONTEND_URL']}/verify-email?token={user.verification_token}" + + msg = Message( + "Verify Your Email", + sender=current_app.config["MAIL_DEFAULT_SENDER"], + recipients=[user.email], + ) + + msg.html = render_template( + "email/verify_email.html", + username=user.username, + verification_url=verification_url, + ) + + msg.body = f""" + Hello {user.username}, + + Thank you for registering. Please verify your email by clicking the link below: + + {verification_url} + + This link will expire in 24 hours. + + If you didn't create an account, please ignore this email. + """ + + mail.send(msg) + return True + except Exception as e: + current_app.logger.error(f"Failed to send verification email: {str(e)}") + return False diff --git a/backend/config.py b/backend/config.py index 94e2e7a..1d810bd 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,5 +1,6 @@ import os from datetime import timedelta + from dotenv import load_dotenv load_dotenv() @@ -11,6 +12,7 @@ class Config: SECRET_KEY = os.getenv("SECRET_KEY") TOKEN_SECRET_KEY = os.getenv("TOKEN_SECRET_KEY") TOKEN_ACCESS_TOKEN_EXPIRES = timedelta(hours=24) + FRONTEND_URL = os.getenv("FRONTEND_URL") MAIL_SERVER = os.getenv("MAIL_SERVER") MAIL_PORT = int(os.getenv("MAIL_PORT")) diff --git a/backend/migrations/versions/017a7e9a4716_add_email_verification.py b/backend/migrations/versions/017a7e9a4716_add_email_verification.py new file mode 100644 index 0000000..87e856c --- /dev/null +++ b/backend/migrations/versions/017a7e9a4716_add_email_verification.py @@ -0,0 +1,48 @@ +"""add_email_verification + +Revision ID: 017a7e9a4716 +Revises: d8c3dab8e779 +Create Date: 2025-01-27 10:55:46.595490 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '017a7e9a4716' +down_revision = 'd8c3dab8e779' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('email_verified', sa.Boolean(), nullable=True)) + batch_op.add_column(sa.Column('verification_token', sa.String(length=100), nullable=True)) + batch_op.add_column(sa.Column('verification_token_expires', sa.DateTime(timezone=True), nullable=True)) + batch_op.alter_column('password', + existing_type=sa.VARCHAR(length=256), + type_=sa.String(length=255), + existing_nullable=False) + batch_op.create_unique_constraint(None, ['verification_token']) + batch_op.drop_column('created_at') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True)) + batch_op.drop_constraint(None, type_='unique') + batch_op.alter_column('password', + existing_type=sa.String(length=255), + type_=sa.VARCHAR(length=256), + existing_nullable=False) + batch_op.drop_column('verification_token_expires') + batch_op.drop_column('verification_token') + batch_op.drop_column('email_verified') + + # ### end Alembic commands ### diff --git a/docker-compose.yml b/docker-compose.yml index ba65928..57d0b7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,7 +40,7 @@ services: context: ./frontend dockerfile: Dockerfile args: - EXPO_PUBLIC_API_URL: http://localhost:80/api + EXPO_PUBLIC_API_URL: ${FRONTEND_URL}/api restart: always ports: - "8081:8081" diff --git a/frontend/app/(auth)/_layout.jsx b/frontend/app/(auth)/_layout.jsx index a5a34e6..782dc6a 100644 --- a/frontend/app/(auth)/_layout.jsx +++ b/frontend/app/(auth)/_layout.jsx @@ -1,22 +1,27 @@ -import { View, Text } from 'react-native' -import React from 'react' import { Stack } from 'expo-router' +import React from 'react' const AuthLayout = () => { return ( - - + + + ) } diff --git a/frontend/app/(auth)/sign-in.jsx b/frontend/app/(auth)/sign-in.jsx index 20658d3..7a5a4ff 100644 --- a/frontend/app/(auth)/sign-in.jsx +++ b/frontend/app/(auth)/sign-in.jsx @@ -1,8 +1,9 @@ import { Link, router, useLocalSearchParams } from 'expo-router' import React, { useEffect, useState } from 'react' -import { ActivityIndicator, Alert, ImageBackground, KeyboardAvoidingView, Platform, ScrollView, Text, View } from 'react-native' +import { ActivityIndicator, ImageBackground, KeyboardAvoidingView, Platform, ScrollView, Text, View } from 'react-native' import CustomButton from '../../components/CustomButton' import FormField from '../../components/FormField' +import ToastMessage from '../../components/ToastMessage' import { useGlobalContext } from '../../context/GlobalProvider' import { API_URL } from '../_layout' @@ -16,6 +17,7 @@ const SignIn = () => { const [isSubmitting, setisSubmitting] = useState(false) const [message, setMessage] = useState('') const [isLoading, setIsLoading] = useState(false) + const [toast, setToast] = useState(null) const { isLoggedIn, setIsLoggedIn, setState } = useGlobalContext() const { username } = useLocalSearchParams() @@ -53,6 +55,17 @@ const SignIn = () => { }) setMessage('') router.replace('/home') + } else if (data.message === 'Email not verified') { + router.replace({ + pathname: '/verify-email', + params: { email: data.email } + }) + } else if (data.message === 'Authentication failed' && data.details === 'User not found') { + setMessage('Account not found. Please check your username or create a new account.') + setForm({ + ...form, + password: '' + }) } else { setMessage(`${data.message}! ${data.details}`) setForm({ @@ -68,12 +81,10 @@ const SignIn = () => { username: '', password: '' }) - const message = 'Internal Server Error. Try again later' - if (isWeb) { - window.alert(message) - } else { - Alert.alert(message) - } + setToast({ + message: 'Error signing in. Please try again later', + type: 'error' + }) }) } @@ -92,6 +103,13 @@ const SignIn = () => { behavior={Platform.OS === 'ios' ? 'padding' : 'height'} > + {toast && ( + setToast(null)} + /> + )} diff --git a/frontend/app/(auth)/sign-up.jsx b/frontend/app/(auth)/sign-up.jsx index 229f83e..4dfb4cd 100644 --- a/frontend/app/(auth)/sign-up.jsx +++ b/frontend/app/(auth)/sign-up.jsx @@ -41,14 +41,17 @@ const SignUp = () => { }).then(res => res.json()) .then(data => { setIsLoading(false) - if (data.message == 'Registration successful') { + if (data.message === 'Registration successful') { setToast({ - message: 'Account created successfully', + message: 'Account created! Please check your email to verify your account', type: 'success' }) setTimeout(() => { - router.replace(`/sign-in?username=${form.username}`) - }, 1500) + router.replace({ + pathname: '/verify-email', + params: { email: form.email } + }) + }, 2000) } else { setMessage(`${data.message}! ${data.details}`) setForm({ diff --git a/frontend/app/(auth)/verify-email.jsx b/frontend/app/(auth)/verify-email.jsx new file mode 100644 index 0000000..bf308c8 --- /dev/null +++ b/frontend/app/(auth)/verify-email.jsx @@ -0,0 +1,169 @@ +import { router, useLocalSearchParams } from 'expo-router' +import React, { useEffect, useState } from 'react' +import { ActivityIndicator, ImageBackground, KeyboardAvoidingView, Platform, ScrollView, Text, View } from 'react-native' +import CustomButton from '../../components/CustomButton' +import ToastMessage from '../../components/ToastMessage' +import { API_URL } from '../_layout' + +const VerifyEmail = () => { + const [isLoading, setIsLoading] = useState(false) + const [message, setMessage] = useState('') + const [toast, setToast] = useState(null) + const { email, token } = useLocalSearchParams() + const isWeb = Platform.OS === 'web' + + useEffect(() => { + if (!email && !token) { + router.push('/sign-up') + return + } + + if (token) { + verifyToken() + } + }, [email, token]) + + const verifyToken = async () => { + setIsLoading(true) + try { + const response = await fetch(`${API_URL}/auth/verify-email/${token}`) + const data = await response.json() + + if (response.ok) { + setToast({ + message: 'Email verified successfully! You can now sign in', + type: 'success' + }) + setTimeout(() => { + router.replace('/sign-in') + }, 2000) + } else { + setMessage(`${data.message}! ${data.details}`) + } + } catch (err) { + console.log(err) + setMessage('Failed to verify email. Please try again later.') + } finally { + setIsLoading(false) + } + } + + const resendVerification = () => { + setIsLoading(true) + fetch(`${API_URL}/auth/resend-verification`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email }) + }) + .then(res => res.json()) + .then(data => { + setIsLoading(false) + if (data.message === 'Verification email sent') { + setToast({ + message: 'Verification email sent! Please check your inbox', + type: 'success' + }) + } else { + setMessage(`${data.message}! ${data.details}`) + } + }) + .catch(err => { + console.log(err) + setIsLoading(false) + setToast({ + message: 'Error sending verification email. Please try again later', + type: 'error' + }) + }) + } + + return ( + + + + {toast && ( + setToast(null)} + /> + )} + + + + Verify Your Email + + {message && ( + + {message} + + )} + + + {token ? ( + + Verifying your email... + + ) : ( + <> + + We've sent a verification email to: + + + {email} + + + Please check your email and click the verification link to activate your account. + + + + + Didn't receive the email? + + + + + )} + + router.push('/sign-in')} + variant='outline' + containerStyles='border border-white py-3 rounded-xl mt-4' + textStyles='text-white' + /> + + + + + + {isLoading && ( + + + + )} + + + ) +} + +export default VerifyEmail \ No newline at end of file diff --git a/nginx.conf b/nginx.conf index 9ab3a6f..1f5a416 100644 --- a/nginx.conf +++ b/nginx.conf @@ -18,5 +18,9 @@ server { location / { proxy_pass http://frontend:8081; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } } \ No newline at end of file