summaryrefslogtreecommitdiffstats
path: root/src/routes/login.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/routes/login.rs')
-rw-r--r--src/routes/login.rs140
1 files changed, 140 insertions, 0 deletions
diff --git a/src/routes/login.rs b/src/routes/login.rs
new file mode 100644
index 0000000..12442e8
--- /dev/null
+++ b/src/routes/login.rs
@@ -0,0 +1,140 @@
+use std::sync::Arc;
+
+use argon2::{Argon2, PasswordHash, PasswordVerifier};
+use axum::{extract::State, http::header::SET_COOKIE, response::IntoResponse, Json};
+use axum_extra::{
+ extract::cookie::{Cookie, SameSite},
+ routing::TypedPath,
+};
+use jsonwebtoken::{EncodingKey, Header};
+use serde::Deserialize;
+
+use crate::{
+ model::{LoginSchema, TokenClaims, User},
+ state::AppState,
+ Error,
+};
+
+#[derive(Debug, Deserialize, TypedPath)]
+#[typed_path("/api/login")]
+pub struct Login;
+
+impl Login {
+ #[tracing::instrument(skip(state, password))]
+ pub async fn post(
+ self,
+ State(state): State<Arc<AppState>>,
+ Json(LoginSchema { email, password }): Json<LoginSchema>,
+ ) -> Result<impl IntoResponse, Error> {
+ let User {
+ uuid,
+ password_hash,
+ ..
+ } = sqlx::query_as!(
+ User,
+ "SELECT * FROM users WHERE email = $1",
+ email.to_ascii_lowercase()
+ )
+ .fetch_optional(&state.pool)
+ .await?
+ .ok_or(Error::LoginInvalid)?;
+
+ Argon2::default()
+ .verify_password(password.as_bytes(), &PasswordHash::new(&password_hash)?)?;
+
+ let token = jsonwebtoken::encode(
+ &Header::default(),
+ &TokenClaims::new(uuid, state.jwt_max_age),
+ &EncodingKey::from_secret(state.jwt_secret.as_ref()),
+ )?;
+
+ let cookie = Cookie::build(("token", token.to_owned()))
+ .path("/")
+ .max_age(state.jwt_max_age)
+ .same_site(SameSite::Lax)
+ .http_only(true)
+ .build();
+
+ let mut response = Json(token).into_response();
+
+ response
+ .headers_mut()
+ .insert(SET_COOKIE, cookie.to_string().parse().unwrap());
+
+ Ok(response)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ use axum::{
+ body::Body,
+ http::{header, Request, StatusCode},
+ };
+ use sqlx::PgPool;
+ use tower::ServiceExt;
+
+ use crate::init_router;
+
+ const JWT_SECRET: &str = "test-jwt-secret-token";
+ const JWT_MAX_AGE: time::Duration = time::Duration::HOUR;
+
+ #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))]
+ async fn test_login_unauthorized(pool: PgPool) -> Result<(), Error> {
+ let state = Arc::new(AppState {
+ pool,
+ jwt_secret: JWT_SECRET.to_string(),
+ jwt_max_age: JWT_MAX_AGE,
+ });
+ let router = init_router(state.clone());
+
+ let user = LoginSchema {
+ email: "adent@earth.sol".to_string(),
+ password: "hunter2".to_string(),
+ };
+
+ let request = Request::builder()
+ .uri("/api/login")
+ .method("POST")
+ .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref())
+ .body(Body::from(serde_json::to_vec(&user).unwrap()))?;
+
+ let response = router.oneshot(request).await.unwrap();
+
+ assert_eq!(StatusCode::UNAUTHORIZED, response.status());
+
+ Ok(())
+ }
+
+ #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))]
+ async fn test_login_ok(pool: PgPool) -> Result<(), Error> {
+ let state = Arc::new(AppState {
+ pool,
+ jwt_secret: JWT_SECRET.to_string(),
+ jwt_max_age: JWT_MAX_AGE,
+ });
+ let router = init_router(state.clone());
+
+ let user = LoginSchema {
+ email: "adent@earth.sol".to_string(),
+ password: "solongandthanksforallthefish".to_string(),
+ };
+
+ let response = router
+ .oneshot(
+ Request::builder()
+ .uri("/api/login")
+ .method("POST")
+ .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref())
+ .body(Body::from(serde_json::to_vec(&user).unwrap()))?,
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(StatusCode::OK, response.status());
+
+ Ok(())
+ }
+}