diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 28444fe..2fdacb2 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -53,6 +53,7 @@ version = "0.1.0" dependencies = [ "axum", "bcrypt", + "bytes", "chrono", "http-body-util", "jsonwebtoken", @@ -138,6 +139,12 @@ dependencies = [ "syn", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -309,6 +316,18 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -319,6 +338,33 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "der" version = "0.7.10" @@ -368,6 +414,44 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -377,6 +461,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -415,6 +520,22 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -526,6 +647,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -564,6 +686,17 @@ dependencies = [ "wasip3", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -892,11 +1025,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ "base64", + "ed25519-dalek", "getrandom 0.2.17", + "hmac", "js-sys", + "p256", + "p384", "pem", + "rand 0.8.6", + "rsa", "serde", "serde_json", + "sha2", "signature", "simple_asn1", ] @@ -1107,6 +1247,30 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -1234,6 +1398,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1358,6 +1531,16 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rsa" version = "0.9.10" @@ -1378,6 +1561,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1396,6 +1588,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ca4aeb4..14f862c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -10,7 +10,7 @@ tokio = { version = "1", features = ["full"] } sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "macros", "migrate"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -jsonwebtoken = "10" +jsonwebtoken = { version = "10", features = ["rust_crypto"] } bcrypt = "0.19" tower-http = { version = "0.6", features = ["fs", "cors"] } chrono = { version = "0.4", features = ["serde"] } @@ -22,3 +22,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } [dev-dependencies] tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" +bytes = "1" diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 1df4b0b..db87274 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -1 +1,56 @@ -// auth — populated in Task 4 +use axum::{extract::FromRequestParts, http::request::Parts}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use crate::error::AppError; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TutorClaims { pub sub: i64, pub email: String, pub exp: usize } + +fn secret() -> String { + std::env::var("JWT_SECRET").expect("JWT_SECRET not set") +} + +pub fn encode_jwt(id: i64, email: &str) -> Result { + let exp = chrono::Utc::now().timestamp() as usize + 86400 * 7; + let claims = TutorClaims { sub: id, email: email.into(), exp }; + encode(&Header::default(), &claims, &EncodingKey::from_secret(secret().as_bytes())) + .map_err(|_| AppError::Unauthorized) +} + +pub fn decode_jwt(token: &str) -> Result { + decode::(token, &DecodingKey::from_secret(secret().as_bytes()), + &Validation::default()) + .map(|d| d.claims) + .map_err(|_| AppError::Unauthorized) +} + +// Axum extractor: pulls JWT from Authorization: Bearer header +impl FromRequestParts for TutorClaims { + type Rejection = AppError; + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + let header = parts.headers.get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or(AppError::Unauthorized)?; + decode_jwt(header) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_jwt() { + unsafe { std::env::set_var("JWT_SECRET", "testsecret"); } + let token = encode_jwt(1, "test@example.com").unwrap(); + let claims = decode_jwt(&token).unwrap(); + assert_eq!(claims.sub, 1); + } + + #[test] + fn invalid_jwt_rejected() { + unsafe { std::env::set_var("JWT_SECRET", "testsecret"); } + assert!(decode_jwt("not.a.token").is_err()); + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 2209dfd..f0545f7 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -4,6 +4,9 @@ mod models; mod auth; mod routes; +#[cfg(test)] +mod test_helpers; + use axum::Router; use tracing_subscriber::EnvFilter; diff --git a/backend/src/routes/auth_routes.rs b/backend/src/routes/auth_routes.rs new file mode 100644 index 0000000..767fe48 --- /dev/null +++ b/backend/src/routes/auth_routes.rs @@ -0,0 +1,62 @@ +use axum::{extract::State, routing::post, Json, Router}; +use serde::Deserialize; +use sqlx::SqlitePool; +use serde_json::{json, Value}; +use crate::{auth, error::AppError}; + +#[derive(Deserialize)] +struct LoginRequest { email: String, password: String } + +async fn login( + State(pool): State, + Json(req): Json, +) -> Result, AppError> { + let tutor: Option<(i64, String, String)> = sqlx::query_as( + "SELECT id, email, password_hash FROM tutors WHERE email = ?" + ).bind(&req.email).fetch_optional(&pool).await?; + + let (id, email, hash) = tutor.ok_or(AppError::Unauthorized)?; + if !bcrypt::verify(&req.password, &hash).unwrap_or(false) { + return Err(AppError::Unauthorized); + } + let token = auth::encode_jwt(id, &email)?; + Ok(Json(json!({"token": token}))) +} + +pub fn router() -> Router { + Router::new().route("/api/auth/login", post(login)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::{build_test_app, post_json}; + use serde_json::json; + + #[sqlx::test(migrations = "./migrations")] + async fn login_returns_token(pool: SqlitePool) { + let hash = bcrypt::hash("secret", bcrypt::DEFAULT_COST).unwrap(); + sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)") + .bind("Test").bind("t@test.com").bind(&hash) + .execute(&pool).await.unwrap(); + + let app = crate::routes::build(pool); + let (status, body) = post_json(app, "/api/auth/login", "", + json!({"email":"t@test.com","password":"secret"})).await; + assert_eq!(status, 200); + assert!(serde_json::from_slice::(&body).unwrap()["token"].is_string()); + } + + #[sqlx::test(migrations = "./migrations")] + async fn login_wrong_password(pool: SqlitePool) { + let hash = bcrypt::hash("correct", bcrypt::DEFAULT_COST).unwrap(); + sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)") + .bind("Test").bind("t@test.com").bind(&hash) + .execute(&pool).await.unwrap(); + + let app = crate::routes::build(pool); + let (status, _) = post_json(app, "/api/auth/login", "", + json!({"email":"t@test.com","password":"wrong"})).await; + assert_eq!(status, 401); + } +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 3197a96..10e0bf1 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -1,6 +1,10 @@ use axum::Router; use sqlx::SqlitePool; -pub fn build(_pool: SqlitePool) -> Router { +mod auth_routes; + +pub fn build(pool: SqlitePool) -> Router { Router::new() + .merge(auth_routes::router()) + .with_state(pool) } diff --git a/backend/src/test_helpers.rs b/backend/src/test_helpers.rs new file mode 100644 index 0000000..7ff5136 --- /dev/null +++ b/backend/src/test_helpers.rs @@ -0,0 +1,55 @@ +// cfg(test) only — this whole module is test-only +use sqlx::SqlitePool; +use axum::Router; +use tower::ServiceExt; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; + +/// Insert a test tutor (if not exists), return a valid JWT for that tutor. +pub async fn make_token(pool: &SqlitePool, email: &str) -> String { + let hash = bcrypt::hash("testpass", bcrypt::DEFAULT_COST).unwrap(); + sqlx::query("INSERT OR IGNORE INTO tutors (name,email,password_hash) VALUES (?,?,?)") + .bind("Test Tutor").bind(email).bind(&hash) + .execute(pool).await.unwrap(); + let id: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = ?") + .bind(email).fetch_one(pool).await.unwrap(); + unsafe { std::env::set_var("JWT_SECRET", "testsecret"); } + crate::auth::encode_jwt(id.0, email).unwrap() +} + +/// Build the full Axum app wired with the given pool, plus a Bearer auth header value. +pub async fn build_test_app(pool: SqlitePool) -> (Router, String) { + unsafe { std::env::set_var("JWT_SECRET", "testsecret"); } + let token = make_token(&pool, "tutor@test.com").await; + let app = crate::routes::build(pool); + (app, format!("Bearer {token}")) +} + +/// POST JSON body to the app (one-shot), returns (StatusCode, response body bytes). +pub async fn post_json(app: Router, path: &str, auth: &str, body: serde_json::Value) + -> (StatusCode, bytes::Bytes) +{ + let req = Request::builder() + .method("POST").uri(path) + .header("Content-Type", "application/json") + .header("Authorization", auth) + .body(axum::body::Body::from(body.to_string())) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + let status = res.status(); + let body = res.into_body().collect().await.unwrap().to_bytes(); + (status, body) +} + +/// GET from the app (one-shot), returns (StatusCode, response body bytes). +pub async fn get(app: Router, path: &str, auth: &str) -> (StatusCode, bytes::Bytes) { + let req = Request::builder() + .method("GET").uri(path) + .header("Authorization", auth) + .body(axum::body::Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + let status = res.status(); + let body = res.into_body().collect().await.unwrap().to_bytes(); + (status, body) +}