feat: JWT auth, login endpoint, and test helpers
This commit is contained in:
206
backend/Cargo.lock
generated
206
backend/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String, AppError> {
|
||||
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<TutorClaims, AppError> {
|
||||
decode::<TutorClaims>(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<S: Send + Sync> FromRequestParts<S> for TutorClaims {
|
||||
type Rejection = AppError;
|
||||
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ mod models;
|
||||
mod auth;
|
||||
mod routes;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_helpers;
|
||||
|
||||
use axum::Router;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
|
||||
62
backend/src/routes/auth_routes.rs
Normal file
62
backend/src/routes/auth_routes.rs
Normal file
@@ -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<SqlitePool>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<Value>, 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<SqlitePool> {
|
||||
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::<Value>(&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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
55
backend/src/test_helpers.rs
Normal file
55
backend/src/test_helpers.rs
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user