From a043c080c38c5fdfdab5ab1c7f52c16845a80b58 Mon Sep 17 00:00:00 2001 From: blackghost Date: Thu, 5 Mar 2026 14:47:43 +0100 Subject: [PATCH] feature:Authentication --- .env.example | 12 + Cargo.lock | 308 ++++++++++++++++ Cargo.toml | 3 + migrations/002_create_otp_table.sql | 17 + src/controllers/auth.rs | 300 +-------------- ...uth.controllers.rs => auth_controllers.rs} | 128 ++++++- src/controllers/mod.rs | 1 + src/controllers/tests.rs | 2 + src/models/user.rs | 2 +- src/routes/auth_routes.rs | 4 + src/services/email_service.rs | 347 ++++++++++++++++++ src/services/mod.rs | 7 + src/services/otp_service.rs | 169 +++++++++ src/services/tests.rs | 264 +++++++++++++ 14 files changed, 1260 insertions(+), 304 deletions(-) create mode 100644 migrations/002_create_otp_table.sql rename src/controllers/{auth.controllers.rs => auth_controllers.rs} (67%) create mode 100644 src/services/email_service.rs create mode 100644 src/services/otp_service.rs create mode 100644 src/services/tests.rs diff --git a/.env.example b/.env.example index 0bd625a..2a8474d 100644 --- a/.env.example +++ b/.env.example @@ -10,5 +10,17 @@ JWT_SECRET=your-super-secret-jwt-key-change-this-in-production JWT_ACCESS_TOKEN_EXPIRY=3600 JWT_REFRESH_TOKEN_EXPIRY=604800 +# SMTP Configuration (Gmail example) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USERNAME=your-email@gmail.com +SMTP_PASSWORD=your-app-specific-password +FROM_EMAIL=noreply@school.com +FROM_NAME=School API + +# Email URLs +RESET_PASSWORD_URL=https://school.com/reset-password +VERIFY_EMAIL_URL=https://school.com/verify-email + # Logging RUST_LOG=info diff --git a/Cargo.lock b/Cargo.lock index 0465601..b0a0033 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -23,6 +35,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -211,6 +232,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "cipher" version = "0.4.4" @@ -236,6 +267,16 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -355,6 +396,22 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "equivalent" version = "1.0.2" @@ -393,6 +450,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -416,6 +479,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -532,6 +610,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -597,6 +685,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "http" version = "1.4.0" @@ -877,6 +976,31 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "base64", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "native-tls", + "nom", + "percent-encoding", + "quoted_printable", + "socket2", + "tokio", + "url", +] + [[package]] name = "libc" version = "0.2.182" @@ -911,6 +1035,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -971,6 +1101,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1042,12 +1198,65 @@ dependencies = [ "libm", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking" version = "2.2.1" @@ -1190,6 +1399,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quote" version = "1.0.44" @@ -1199,6 +1418,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r-efi" version = "5.3.0" @@ -1287,6 +1512,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.37" @@ -1333,6 +1571,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "school-backend" version = "0.1.0" @@ -1342,6 +1589,8 @@ dependencies = [ "chrono", "dotenv", "jsonwebtoken", + "lettre", + "rand", "serde", "serde_json", "sqlx", @@ -1360,6 +1609,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -1749,6 +2021,20 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.52.0", + "windows-sys 0.59.0", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -1794,6 +2080,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2345,6 +2644,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 99629a4..f071426 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,4 +19,7 @@ chrono = { version = "0.4", features = ["serde"] } jsonwebtoken = "9.0" bcrypt = "0.15" thiserror = "1.0" +lettre = { version = "0.11", features = ["builder", "hostname"] } +rand = "0.8" + diff --git a/migrations/002_create_otp_table.sql b/migrations/002_create_otp_table.sql new file mode 100644 index 0000000..0efe57f --- /dev/null +++ b/migrations/002_create_otp_table.sql @@ -0,0 +1,17 @@ +-- Create OTP records table +CREATE TABLE IF NOT EXISTS otp_records ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + otp_code VARCHAR(6) NOT NULL, + otp_type VARCHAR(50) NOT NULL, -- 'login', 'password_reset', 'email_verification' + is_used BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT valid_otp_type CHECK (otp_type IN ('login', 'password_reset', 'email_verification')) +); + +-- Create indexes for faster queries +CREATE INDEX idx_otp_user_id ON otp_records(user_id); +CREATE INDEX idx_otp_type ON otp_records(otp_type); +CREATE INDEX idx_otp_expires_at ON otp_records(expires_at); +CREATE INDEX idx_otp_user_type ON otp_records(user_id, otp_type); diff --git a/src/controllers/auth.rs b/src/controllers/auth.rs index 086c78a..d9ef018 100644 --- a/src/controllers/auth.rs +++ b/src/controllers/auth.rs @@ -1,299 +1 @@ -use axum::{ - extract::State, - http::{header, StatusCode}, - response::IntoResponse, - Json, -}; -use serde_json::json; -use sqlx::PgPool; -use uuid::Uuid; - -use crate::models::{ - AuthResponse, LoginRequest, RefreshTokenRequest, RegisterRequest, UserResponse, -}; -use crate::services::AuthService; -use crate::utils::{AuthError, JwtConfig}; - -pub struct AuthController; - -impl AuthController { - /// Register a new Admin user - /// POST /auth/admin/register - pub async fn register_admin( - State((pool, jwt_config)): State<(PgPool, JwtConfig)>, - Json(mut req): Json, - ) -> Result { - // Force role to admin - req.role = "admin".to_string(); - - let user = AuthService::register(&pool, req).await?; - - let (access_token, refresh_token) = crate::utils::generate_tokens( - user.id, - user.email.clone(), - user.role.clone(), - &jwt_config, - ) - .map_err(|_| AuthError::InternalServerError)?; - - let response = AuthResponse { - user, - access_token, - refresh_token, - token_type: "Bearer".to_string(), - expires_in: jwt_config.access_token_expiry, - }; - - Ok((StatusCode::CREATED, Json(response))) - } - - /// Login as Admin - /// POST /auth/admin/login - pub async fn login_admin( - State((pool, jwt_config)): State<(PgPool, JwtConfig)>, - Json(req): Json, - ) -> Result { - let user = AuthService::login(&pool, req).await?; - - // Verify user is admin - AuthService::validate_role(&user.role, &["admin"])?; - - let (access_token, refresh_token) = crate::utils::generate_tokens( - user.id, - user.email.clone(), - user.role.clone(), - &jwt_config, - ) - .map_err(|_| AuthError::InternalServerError)?; - - let response = AuthResponse { - user: UserResponse::from(user), - access_token, - refresh_token, - token_type: "Bearer".to_string(), - expires_in: jwt_config.access_token_expiry, - }; - - Ok((StatusCode::OK, Json(response))) - } - - /// Register a new Student user - /// POST /auth/student/register - pub async fn register_student( - State((pool, jwt_config)): State<(PgPool, JwtConfig)>, - Json(mut req): Json, - ) -> Result { - // Force role to student - req.role = "student".to_string(); - - let user = AuthService::register(&pool, req).await?; - - let (access_token, refresh_token) = crate::utils::generate_tokens( - user.id, - user.email.clone(), - user.role.clone(), - &jwt_config, - ) - .map_err(|_| AuthError::InternalServerError)?; - - let response = AuthResponse { - user, - access_token, - refresh_token, - token_type: "Bearer".to_string(), - expires_in: jwt_config.access_token_expiry, - }; - - Ok((StatusCode::CREATED, Json(response))) - } - - /// Login as Student - /// POST /auth/student/login - pub async fn login_student( - State((pool, jwt_config)): State<(PgPool, JwtConfig)>, - Json(req): Json, - ) -> Result { - let user = AuthService::login(&pool, req).await?; - - // Verify user is student - AuthService::validate_role(&user.role, &["student"])?; - - let (access_token, refresh_token) = crate::utils::generate_tokens( - user.id, - user.email.clone(), - user.role.clone(), - &jwt_config, - ) - .map_err(|_| AuthError::InternalServerError)?; - - let response = AuthResponse { - user: UserResponse::from(user), - access_token, - refresh_token, - token_type: "Bearer".to_string(), - expires_in: jwt_config.access_token_expiry, - }; - - Ok((StatusCode::OK, Json(response))) - } - - /// Register a new Mentor user - /// POST /auth/mentor/register - pub async fn register_mentor( - State((pool, jwt_config)): State<(PgPool, JwtConfig)>, - Json(mut req): Json, - ) -> Result { - // Force role to mentor - req.role = "mentor".to_string(); - - let user = AuthService::register(&pool, req).await?; - - let (access_token, refresh_token) = crate::utils::generate_tokens( - user.id, - user.email.clone(), - user.role.clone(), - &jwt_config, - ) - .map_err(|_| AuthError::InternalServerError)?; - - let response = AuthResponse { - user, - access_token, - refresh_token, - token_type: "Bearer".to_string(), - expires_in: jwt_config.access_token_expiry, - }; - - Ok((StatusCode::CREATED, Json(response))) - } - - /// Login as Mentor - /// POST /auth/mentor/login - pub async fn login_mentor( - State((pool, jwt_config)): State<(PgPool, JwtConfig)>, - Json(req): Json, - ) -> Result { - let user = AuthService::login(&pool, req).await?; - - // Verify user is mentor - AuthService::validate_role(&user.role, &["mentor"])?; - - let (access_token, refresh_token) = crate::utils::generate_tokens( - user.id, - user.email.clone(), - user.role.clone(), - &jwt_config, - ) - .map_err(|_| AuthError::InternalServerError)?; - - let response = AuthResponse { - user: UserResponse::from(user), - access_token, - refresh_token, - token_type: "Bearer".to_string(), - expires_in: jwt_config.access_token_expiry, - }; - - Ok((StatusCode::OK, Json(response))) - } - - /// Refresh access token for any authenticated user - /// POST /auth/refresh - pub async fn refresh_token( - State((pool, jwt_config)): State<(PgPool, JwtConfig)>, - Json(req): Json, - ) -> Result { - // Verify refresh token - let claims = crate::utils::verify_token(&req.refresh_token, &jwt_config) - .map_err(|_| AuthError::InvalidToken)?; - - // Ensure it's a refresh token - if claims.token_type != "refresh" { - return Err(AuthError::InvalidToken); - } - - let user_id = Uuid::parse_str(&claims.sub) - .map_err(|_| AuthError::InvalidToken)?; - - let (access_token, expires_in) = - AuthService::refresh_access_token(&pool, user_id, &jwt_config).await?; - - let response = json!({ - "access_token": access_token, - "token_type": "Bearer", - "expires_in": expires_in, - }); - - Ok((StatusCode::OK, Json(response))) - } - - /// Logout user (client-side token invalidation) - /// POST /auth/logout - pub async fn logout() -> impl IntoResponse { - let response = json!({ - "message": "Logged out successfully. Please discard the tokens on client side." - }); - - (StatusCode::OK, Json(response)) - } - - /// Get current user profile (requires valid access token) - /// GET /auth/me - pub async fn get_current_user( - State((pool, jwt_config)): State<(PgPool, JwtConfig)>, - headers: axum::http::HeaderMap, - ) -> Result { - // Extract token from Authorization header - let auth_header = headers - .get(header::AUTHORIZATION) - .and_then(|h| h.to_str().ok()) - .ok_or(AuthError::Unauthorized)?; - - let token = crate::utils::extract_token_from_header(auth_header) - .ok_or(AuthError::InvalidToken)?; - - // Verify token - let claims = crate::utils::verify_token(&token, &jwt_config) - .map_err(|_| AuthError::InvalidToken)?; - - // Ensure it's an access token - if claims.token_type != "access" { - return Err(AuthError::InvalidToken); - } - - let user_id = Uuid::parse_str(&claims.sub) - .map_err(|_| AuthError::InvalidToken)?; - - let user = AuthService::get_user_by_id(&pool, user_id).await?; - - Ok((StatusCode::OK, Json(UserResponse::from(user)))) - } - - /// Verify token validity - /// POST /auth/verify - pub async fn verify_token_endpoint( - State((_pool, jwt_config)): State<(PgPool, JwtConfig)>, - headers: axum::http::HeaderMap, - ) -> Result { - let auth_header = headers - .get(header::AUTHORIZATION) - .and_then(|h| h.to_str().ok()) - .ok_or(AuthError::Unauthorized)?; - - let token = crate::utils::extract_token_from_header(auth_header) - .ok_or(AuthError::InvalidToken)?; - - let claims = crate::utils::verify_token(&token, &jwt_config) - .map_err(|_| AuthError::InvalidToken)?; - - let response = json!({ - "valid": true, - "user_id": claims.sub, - "email": claims.email, - "role": claims.role, - "token_type": claims.token_type, - }); - - Ok((StatusCode::OK, Json(response))) - } -} +pub use crate::controllers::auth_controllers::*; diff --git a/src/controllers/auth.controllers.rs b/src/controllers/auth_controllers.rs similarity index 67% rename from src/controllers/auth.controllers.rs rename to src/controllers/auth_controllers.rs index 086c78a..d484515 100644 --- a/src/controllers/auth.controllers.rs +++ b/src/controllers/auth_controllers.rs @@ -11,9 +11,20 @@ use uuid::Uuid; use crate::models::{ AuthResponse, LoginRequest, RefreshTokenRequest, RegisterRequest, UserResponse, }; -use crate::services::AuthService; +use crate::services::{AuthService, EmailService, OtpService}; use crate::utils::{AuthError, JwtConfig}; +#[derive(serde::Deserialize)] +pub struct OtpVerificationRequest { + pub email: String, + pub otp: String, +} + +#[derive(serde::Deserialize)] +pub struct ResendOtpRequest { + pub email: String, +} + pub struct AuthController; impl AuthController { @@ -26,7 +37,19 @@ impl AuthController { // Force role to admin req.role = "admin".to_string(); - let user = AuthService::register(&pool, req).await?; + let user = AuthService::register(&pool, req.clone()).await?; + + // Send welcome email + let email_config = crate::services::EmailConfig::from_env()?; + let email_service = EmailService::new(email_config); + + let full_name = format!("{} {}", user.first_name, user.last_name); + if let Err(e) = email_service + .send_welcome_email(&user.email, &full_name, &user.role) + .await + { + tracing::warn!("Failed to send welcome email: {:?}", e); + } let (access_token, refresh_token) = crate::utils::generate_tokens( user.id, @@ -47,10 +70,10 @@ impl AuthController { Ok((StatusCode::CREATED, Json(response))) } - /// Login as Admin + /// Login as Admin with OTP /// POST /auth/admin/login pub async fn login_admin( - State((pool, jwt_config)): State<(PgPool, JwtConfig)>, + State((pool, _jwt_config)): State<(PgPool, JwtConfig)>, Json(req): Json, ) -> Result { let user = AuthService::login(&pool, req).await?; @@ -58,6 +81,46 @@ impl AuthController { // Verify user is admin AuthService::validate_role(&user.role, &["admin"])?; + // Generate and send OTP + let otp = OtpService::create_otp(&pool, user.id, "login", 10).await?; + + let email_config = crate::services::EmailConfig::from_env()?; + let email_service = EmailService::new(email_config); + + let full_name = format!("{} {}", user.first_name, user.last_name); + email_service + .send_otp_email(&user.email, &otp, &full_name) + .await?; + + let response = json!({ + "message": "OTP sent to your email", + "user_id": user.id, + "email": user.email, + "requires_otp": true + }); + + Ok((StatusCode::OK, Json(response))) + } + + /// Verify OTP for login + /// POST /auth/verify-otp + pub async fn verify_otp_login( + State((pool, jwt_config)): State<(PgPool, JwtConfig)>, + Json(req): Json, + ) -> Result { + // Find user by email + let user = sqlx::query_as::<_, crate::models::User>( + "SELECT id, email, password_hash, first_name, last_name, role, is_active, created_at, updated_at FROM users WHERE email = $1" + ) + .bind(&req.email) + .fetch_optional(&pool) + .await + .map_err(|e| AuthError::DatabaseError(e.to_string()))? + .ok_or(AuthError::InvalidCredentials)?; + + // Verify OTP + OtpService::verify_otp(&pool, user.id, &req.otp, "login").await?; + let (access_token, refresh_token) = crate::utils::generate_tokens( user.id, user.email.clone(), @@ -77,6 +140,39 @@ impl AuthController { Ok((StatusCode::OK, Json(response))) } + /// Resend OTP + /// POST /auth/resend-otp + pub async fn resend_otp( + State((pool, _jwt_config)): State<(PgPool, JwtConfig)>, + Json(req): Json, + ) -> Result { + let user = sqlx::query_as::<_, crate::models::User>( + "SELECT id, email, password_hash, first_name, last_name, role, is_active, created_at, updated_at FROM users WHERE email = $1" + ) + .bind(&req.email) + .fetch_optional(&pool) + .await + .map_err(|e| AuthError::DatabaseError(e.to_string()))? + .ok_or(AuthError::UserNotFound)?; + + let otp = OtpService::resend_otp(&pool, user.id, "login").await?; + + let email_config = crate::services::EmailConfig::from_env()?; + let email_service = EmailService::new(email_config); + + let full_name = format!("{} {}", user.first_name, user.last_name); + email_service + .send_otp_email(&user.email, &otp, &full_name) + .await?; + + let response = json!({ + "message": "OTP resent to your email", + "email": user.email + }); + + Ok((StatusCode::OK, Json(response))) + } + /// Register a new Student user /// POST /auth/student/register pub async fn register_student( @@ -88,6 +184,18 @@ impl AuthController { let user = AuthService::register(&pool, req).await?; + // Send welcome email + let email_config = crate::services::EmailConfig::from_env()?; + let email_service = EmailService::new(email_config); + + let full_name = format!("{} {}", user.first_name, user.last_name); + if let Err(e) = email_service + .send_welcome_email(&user.email, &full_name, &user.role) + .await + { + tracing::warn!("Failed to send welcome email: {:?}", e); + } + let (access_token, refresh_token) = crate::utils::generate_tokens( user.id, user.email.clone(), @@ -148,6 +256,18 @@ impl AuthController { let user = AuthService::register(&pool, req).await?; + // Send welcome email + let email_config = crate::services::EmailConfig::from_env()?; + let email_service = EmailService::new(email_config); + + let full_name = format!("{} {}", user.first_name, user.last_name); + if let Err(e) = email_service + .send_welcome_email(&user.email, &full_name, &user.role) + .await + { + tracing::warn!("Failed to send welcome email: {:?}", e); + } + let (access_token, refresh_token) = crate::utils::generate_tokens( user.id, user.email.clone(), diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 40cf41a..00e33cf 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod auth_controllers; pub mod admin; pub mod student; pub mod mentor; diff --git a/src/controllers/tests.rs b/src/controllers/tests.rs index 9aafe87..352fc1e 100644 --- a/src/controllers/tests.rs +++ b/src/controllers/tests.rs @@ -71,6 +71,7 @@ mod controller_tests { assert_eq!(admin.email, "admin@example.com"); assert_eq!(admin.user_id, user_id); + assert!(!admin.email.is_empty()); } #[test] @@ -893,6 +894,7 @@ mod controller_tests { assert!(school_admin.email.contains("admin")); assert_eq!(school_admin.user_id, user_id); + assert!(!school_admin.email.is_empty()); } #[test] diff --git a/src/models/user.rs b/src/models/user.rs index f61c7ac..08c11e2 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -76,7 +76,7 @@ impl From for UserResponse { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct RegisterRequest { pub email: String, pub password: String, diff --git a/src/routes/auth_routes.rs b/src/routes/auth_routes.rs index 3540174..036da13 100644 --- a/src/routes/auth_routes.rs +++ b/src/routes/auth_routes.rs @@ -21,6 +21,10 @@ pub fn auth_routes(pool: PgPool, jwt_config: JwtConfig) -> Router { .route("/mentor/register", post(AuthController::register_mentor)) .route("/mentor/login", post(AuthController::login_mentor)) + // OTP routes + .route("/verify-otp", post(AuthController::verify_otp_login)) + .route("/resend-otp", post(AuthController::resend_otp)) + // Common authentication routes .route("/refresh", post(AuthController::refresh_token)) .route("/logout", post(AuthController::logout)) diff --git a/src/services/email_service.rs b/src/services/email_service.rs new file mode 100644 index 0000000..a7da21e --- /dev/null +++ b/src/services/email_service.rs @@ -0,0 +1,347 @@ +use lettre::message::MultiPart; +use lettre::transport::smtp::authentication::Credentials; +use lettre::transport::smtp::SmtpTransport; +use lettre::{Message, Transport}; +use serde::{Deserialize, Serialize}; +use std::env; +use tracing::{error, info}; + +use crate::utils::AuthError; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmailConfig { + pub smtp_host: String, + pub smtp_port: u16, + pub smtp_username: String, + pub smtp_password: String, + pub from_email: String, + pub from_name: String, +} + +impl EmailConfig { + pub fn from_env() -> Result { + Ok(EmailConfig { + smtp_host: env::var("SMTP_HOST") + .unwrap_or_else(|_| "smtp.gmail.com".to_string()), + smtp_port: env::var("SMTP_PORT") + .unwrap_or_else(|_| "587".to_string()) + .parse() + .unwrap_or(587), + smtp_username: env::var("SMTP_USERNAME") + .map_err(|_| AuthError::InternalServerError)?, + smtp_password: env::var("SMTP_PASSWORD") + .map_err(|_| AuthError::InternalServerError)?, + from_email: env::var("FROM_EMAIL") + .map_err(|_| AuthError::InternalServerError)?, + from_name: env::var("FROM_NAME") + .unwrap_or_else(|_| "School API".to_string()), + }) + } +} + +#[derive(Debug, Clone)] +pub struct EmailService { + pub config: EmailConfig, +} + +impl EmailService { + pub fn new(config: EmailConfig) -> Self { + EmailService { config } + } + + /// Send email with HTML and plain text content + pub async fn send_email( + &self, + to_email: &str, + subject: &str, + html_body: &str, + text_body: &str, + ) -> Result<(), AuthError> { + let from = format!("{} <{}>", self.config.from_name, self.config.from_email) + .parse() + .map_err(|_| AuthError::InternalServerError)?; + + let to = to_email + .parse() + .map_err(|_| AuthError::InternalServerError)?; + + let message = Message::builder() + .from(from) + .to(to) + .subject(subject) + .multipart(MultiPart::alternative() + .singlepart(lettre::message::SinglePart::plain(text_body.to_string())) + .singlepart(lettre::message::SinglePart::html(html_body.to_string()))) + .map_err(|_| AuthError::InternalServerError)?; + + let creds = Credentials::new( + self.config.smtp_username.clone().into(), + self.config.smtp_password.clone().into(), + ); + + let mailer = SmtpTransport::relay(&self.config.smtp_host) + .map_err(|e| { + error!("SMTP relay error: {}", e); + AuthError::InternalServerError + })? + .port(self.config.smtp_port) + .credentials(creds) + .build(); + + mailer.send(&message).map_err(|e| { + error!("Failed to send email: {}", e); + AuthError::InternalServerError + })?; + + info!("Email sent successfully to {}", to_email); + Ok(()) + } + + /// Send OTP email + pub async fn send_otp_email( + &self, + to_email: &str, + otp: &str, + user_name: &str, + ) -> Result<(), AuthError> { + let subject = "Your OTP for School API"; + let html_body = EmailTemplate::otp_email(user_name, otp); + let text_body = format!( + "Hello {},\n\nYour OTP is: {}\n\nThis OTP will expire in 10 minutes.\n\nBest regards,\nSchool API Team", + user_name, otp + ); + + self.send_email(to_email, subject, &html_body, &text_body) + .await + } + + /// Send password reset email + pub async fn send_password_reset_email( + &self, + to_email: &str, + reset_token: &str, + user_name: &str, + ) -> Result<(), AuthError> { + let subject = "Password Reset Request"; + let reset_link = format!( + "{}?token={}", + env::var("RESET_PASSWORD_URL") + .unwrap_or_else(|_| "https://school.com/reset-password".to_string()), + reset_token + ); + let html_body = EmailTemplate::password_reset_email(user_name, &reset_link); + let text_body = format!( + "Hello {},\n\nClick the link below to reset your password:\n{}\n\nThis link will expire in 1 hour.\n\nBest regards,\nSchool API Team", + user_name, reset_link + ); + + self.send_email(to_email, subject, &html_body, &text_body) + .await + } + + /// Send welcome email + pub async fn send_welcome_email( + &self, + to_email: &str, + user_name: &str, + role: &str, + ) -> Result<(), AuthError> { + let subject = "Welcome to School API"; + let html_body = EmailTemplate::welcome_email(user_name, role); + let text_body = format!( + "Hello {},\n\nWelcome to School API! Your account has been created with role: {}.\n\nBest regards,\nSchool API Team", + user_name, role + ); + + self.send_email(to_email, subject, &html_body, &text_body) + .await + } + + /// Send account verification email + pub async fn send_verification_email( + &self, + to_email: &str, + verification_token: &str, + user_name: &str, + ) -> Result<(), AuthError> { + let subject = "Verify Your Email Address"; + let verify_link = format!( + "{}?token={}", + env::var("VERIFY_EMAIL_URL") + .unwrap_or_else(|_| "https://school.com/verify-email".to_string()), + verification_token + ); + let html_body = EmailTemplate::verification_email(user_name, &verify_link); + let text_body = format!( + "Hello {},\n\nClick the link below to verify your email:\n{}\n\nThis link will expire in 24 hours.\n\nBest regards,\nSchool API Team", + user_name, verify_link + ); + + self.send_email(to_email, subject, &html_body, &text_body) + .await + } +} + +/// Email template generator +pub struct EmailTemplate; + +impl EmailTemplate { + pub fn otp_email(user_name: &str, otp: &str) -> String { + format!( + r#" + + + + + + + +
+
+

School API

+
+
+

Hello {},

+

Your One-Time Password (OTP) for authentication is:

+
+
{}
+
+

Important: This OTP will expire in 10 minutes. Do not share this code with anyone.

+

If you did not request this OTP, please ignore this email.

+
+ +
+ + + "#, + user_name, otp + ) + } + + pub fn password_reset_email(user_name: &str, reset_link: &str) -> String { + format!( + r#" + + + + + + + +
+
+

Password Reset Request

+
+
+

Hello {},

+

We received a request to reset your password. Click the button below to proceed:

+ Reset Password +

Important: This link will expire in 1 hour.

+

If you did not request a password reset, please ignore this email and your password will remain unchanged.

+
+ +
+ + + "#, + user_name, reset_link + ) + } + + pub fn welcome_email(user_name: &str, role: &str) -> String { + format!( + r#" + + + + + + + +
+
+

Welcome to School API

+
+
+

Hello {},

+

Your account has been successfully created!

+

Your Role: {}

+

You can now log in to your account and start using School API.

+

If you have any questions, please contact our support team.

+
+ +
+ + + "#, + user_name, role + ) + } + + pub fn verification_email(user_name: &str, verify_link: &str) -> String { + format!( + r#" + + + + + + + +
+
+

Verify Your Email Address

+
+
+

Hello {},

+

Thank you for signing up! Please verify your email address by clicking the button below:

+ Verify Email +

Important: This link will expire in 24 hours.

+

If you did not create this account, please ignore this email.

+
+ +
+ + + "#, + user_name, verify_link + ) + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 2702ed8..a9bb865 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,3 +1,10 @@ pub mod auth_services; +pub mod email_service; +pub mod otp_service; + +#[cfg(test)] +mod tests; pub use auth_services::*; +pub use email_service::*; +pub use otp_service::*; diff --git a/src/services/otp_service.rs b/src/services/otp_service.rs new file mode 100644 index 0000000..de8bc95 --- /dev/null +++ b/src/services/otp_service.rs @@ -0,0 +1,169 @@ +use chrono::{Duration, Utc}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::utils::AuthError; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct OtpRecord { + pub id: Uuid, + pub user_id: Uuid, + pub otp_code: String, + pub otp_type: String, // "login", "password_reset", "email_verification" + pub is_used: bool, + pub created_at: chrono::DateTime, + pub expires_at: chrono::DateTime, +} + +pub struct OtpService; + +impl OtpService { + /// Generate a random 6-digit OTP + pub fn generate_otp() -> String { + let mut rng = rand::thread_rng(); + let otp: u32 = rng.gen_range(100000..999999); + otp.to_string() + } + + /// Create and store OTP in database + pub async fn create_otp( + pool: &PgPool, + user_id: Uuid, + otp_type: &str, + expiry_minutes: i64, + ) -> Result { + let otp_code = Self::generate_otp(); + let now = Utc::now(); + let expires_at = now + Duration::minutes(expiry_minutes); + + sqlx::query( + "INSERT INTO otp_records (id, user_id, otp_code, otp_type, is_used, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)" + ) + .bind(Uuid::new_v4()) + .bind(user_id) + .bind(&otp_code) + .bind(otp_type) + .bind(false) + .bind(now) + .bind(expires_at) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to create OTP: {}", e); + AuthError::DatabaseError(e.to_string()) + })?; + + Ok(otp_code) + } + + /// Verify OTP + pub async fn verify_otp( + pool: &PgPool, + user_id: Uuid, + otp_code: &str, + otp_type: &str, + ) -> Result { + let now = Utc::now(); + + let record = sqlx::query_as::<_, OtpRecord>( + "SELECT id, user_id, otp_code, otp_type, is_used, created_at, expires_at + FROM otp_records + WHERE user_id = $1 AND otp_code = $2 AND otp_type = $3 AND is_used = false + ORDER BY created_at DESC + LIMIT 1" + ) + .bind(user_id) + .bind(otp_code) + .bind(otp_type) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!("Failed to fetch OTP: {}", e); + AuthError::DatabaseError(e.to_string()) + })? + .ok_or(AuthError::InvalidToken)?; + + // Check if OTP is expired + if now > record.expires_at { + return Err(AuthError::InvalidToken); + } + + // Mark OTP as used + sqlx::query("UPDATE otp_records SET is_used = true WHERE id = $1") + .bind(record.id) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to mark OTP as used: {}", e); + AuthError::DatabaseError(e.to_string()) + })?; + + Ok(true) + } + + /// Get latest OTP for user + pub async fn get_latest_otp( + pool: &PgPool, + user_id: Uuid, + otp_type: &str, + ) -> Result, AuthError> { + sqlx::query_as::<_, OtpRecord>( + "SELECT id, user_id, otp_code, otp_type, is_used, created_at, expires_at + FROM otp_records + WHERE user_id = $1 AND otp_type = $2 AND is_used = false + ORDER BY created_at DESC + LIMIT 1" + ) + .bind(user_id) + .bind(otp_type) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!("Failed to fetch OTP: {}", e); + AuthError::DatabaseError(e.to_string()) + }) + } + + /// Clean up expired OTPs + pub async fn cleanup_expired_otps(pool: &PgPool) -> Result { + let now = Utc::now(); + + let result = sqlx::query("DELETE FROM otp_records WHERE expires_at < $1") + .bind(now) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to cleanup expired OTPs: {}", e); + AuthError::DatabaseError(e.to_string()) + })?; + + Ok(result.rows_affected()) + } + + /// Resend OTP (invalidate old one and create new) + pub async fn resend_otp( + pool: &PgPool, + user_id: Uuid, + otp_type: &str, + ) -> Result { + // Invalidate old OTPs + sqlx::query( + "UPDATE otp_records SET is_used = true + WHERE user_id = $1 AND otp_type = $2 AND is_used = false" + ) + .bind(user_id) + .bind(otp_type) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to invalidate old OTPs: {}", e); + AuthError::DatabaseError(e.to_string()) + })?; + + // Create new OTP + Self::create_otp(pool, user_id, otp_type, 10).await + } +} diff --git a/src/services/tests.rs b/src/services/tests.rs new file mode 100644 index 0000000..d2136c4 --- /dev/null +++ b/src/services/tests.rs @@ -0,0 +1,264 @@ +#[cfg(test)] +mod email_template_tests { + use crate::services::{EmailTemplate, OtpService}; + + #[test] + fn test_otp_email_generation() { + let user_name = "Test User"; + let otp = "123456"; + let html = EmailTemplate::otp_email(user_name, otp); + + assert!(html.contains(user_name)); + assert!(html.contains(otp)); + assert!(html.contains("")); + } + + #[test] + fn test_otp_email_with_different_values() { + let user_name = "Test User"; + let otp = "654321"; + let html = EmailTemplate::otp_email(user_name, otp); + + assert!(html.contains(user_name)); + assert!(html.contains(otp)); + } + + #[test] + fn test_password_reset_email_generation() { + let user_name = "User"; + let reset_link = "https://example.com"; + let html = EmailTemplate::password_reset_email(user_name, reset_link); + + assert!(html.contains(user_name)); + assert!(html.contains(reset_link)); + assert!(html.contains("")); + } + + #[test] + fn test_password_reset_email_with_different_values() { + let user_name = "User"; + let reset_link = "https://example.com"; + let html = EmailTemplate::password_reset_email(user_name, reset_link); + + assert!(html.contains(user_name)); + assert!(html.contains(reset_link)); + } + + #[test] + fn test_password_reset_email_has_reset_button() { + let user_name = "User"; + let reset_link = "https://example.com"; + let html = EmailTemplate::password_reset_email(user_name, reset_link); + + assert!(html.contains("Reset Password")); + } + + #[test] + fn test_welcome_email_generation() { + let user_name = "User"; + let role = "student"; + let html = EmailTemplate::welcome_email(user_name, role); + + assert!(html.contains(user_name)); + assert!(html.contains(role)); + assert!(html.contains("")); + } + + #[test] + fn test_welcome_email_with_different_role() { + let user_name = "User"; + let role = "student"; + let html = EmailTemplate::welcome_email(user_name, role); + + assert!(html.contains(user_name)); + assert!(html.contains(role)); + } + + #[test] + fn test_welcome_email_with_multiple_roles() { + let roles = vec!["admin", "mentor", "student"]; + for role in roles { + let html = EmailTemplate::welcome_email("User", role); + assert!(html.contains(role)); + } + } + + #[test] + fn test_verification_email_generation() { + let user_name = "User"; + let verify_link = "https://example.com"; + let html = EmailTemplate::verification_email(user_name, verify_link); + + assert!(html.contains(user_name)); + assert!(html.contains(verify_link)); + assert!(html.contains("")); + } + + #[test] + fn test_verification_email_with_different_values() { + let user_name = "User"; + let verify_link = "https://example.com"; + let html = EmailTemplate::verification_email(user_name, verify_link); + + assert!(html.contains(user_name)); + assert!(html.contains(verify_link)); + } + + #[test] + fn test_email_templates_contain_footer() { + let templates = vec![ + EmailTemplate::otp_email("User", "123456"), + EmailTemplate::password_reset_email("User", "https://example.com"), + EmailTemplate::welcome_email("User", "admin"), + EmailTemplate::verification_email("User", "https://example.com"), + ]; + + for template in templates { + assert!(template.contains("footer")); + } + } + + #[test] + fn test_email_templates_contain_styles() { + let templates = vec![ + EmailTemplate::otp_email("User", "123456"), + EmailTemplate::password_reset_email("User", "https://example.com"), + EmailTemplate::welcome_email("User", "admin"), + EmailTemplate::verification_email("User", "https://example.com"), + ]; + + for template in templates { + assert!(template.contains("