From a92fb07d23fb2268a6f4e650c5cbd00ad993e760 Mon Sep 17 00:00:00 2001
From: Santo Cariotti <santo@dcariotti.me>
Date: Wed, 21 Aug 2024 13:25:55 +0200
Subject: Add login

Fields sent are

```
{
  "query": "mutation Login($input: LoginCredentials!) { login(input: $input) { accessToken tokenType } }",
  "variables": {
    "input": {
      "email": "....",
      "password": "..."
    }
  }
}
```
---
 src/graphql/mod.rs       |  1 +
 src/graphql/mutation.rs  | 35 +++++++++++++++++++++
 src/graphql/routes.rs    |  7 +++--
 src/graphql/types/jwt.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++++++
 src/graphql/types/mod.rs |  1 +
 src/main.rs              | 12 +++++---
 6 files changed, 128 insertions(+), 7 deletions(-)
 create mode 100644 src/graphql/mutation.rs
 create mode 100644 src/graphql/types/jwt.rs

(limited to 'src')

diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs
index b394fc1..425faca 100644
--- a/src/graphql/mod.rs
+++ b/src/graphql/mod.rs
@@ -1,3 +1,4 @@
+pub mod mutation;
 pub mod query;
 pub mod routes;
 pub mod types;
diff --git a/src/graphql/mutation.rs b/src/graphql/mutation.rs
new file mode 100644
index 0000000..9321653
--- /dev/null
+++ b/src/graphql/mutation.rs
@@ -0,0 +1,35 @@
+use crate::graphql::types::jwt;
+use crate::state::AppState;
+use async_graphql::{Context, Error, FieldResult, Object};
+
+pub struct Mutation;
+
+#[Object]
+impl Mutation {
+    async fn login<'ctx>(
+        &self,
+        ctx: &Context<'ctx>,
+        input: jwt::LoginCredentials,
+    ) -> FieldResult<jwt::AuthBody> {
+        let state = ctx.data::<AppState>().expect("Can't connect to db");
+        let client = &*state.client;
+
+        let password = sha256::digest(input.password);
+        let rows = client
+            .query(
+                "SELECT id FROM users WHERE email = $1 AND password = $2",
+                &[&input.email, &password],
+            )
+            .await
+            .unwrap();
+
+        let id: Vec<i32> = rows.iter().map(|row| row.get(0)).collect();
+        if id.len() == 1 {
+            let claims = jwt::Claims::new(id[0]);
+            let token = claims.get_token().unwrap();
+            Ok(jwt::AuthBody::new(token))
+        } else {
+            Err(Error::new("Invalid email or password"))
+        }
+    }
+}
diff --git a/src/graphql/routes.rs b/src/graphql/routes.rs
index 2380760..e15267e 100644
--- a/src/graphql/routes.rs
+++ b/src/graphql/routes.rs
@@ -1,10 +1,11 @@
-use crate::graphql::query::*;
-use async_graphql::{EmptyMutation, EmptySubscription, Schema};
+use crate::graphql::mutation::Mutation;
+use crate::graphql::query::Query;
+use async_graphql::{EmptySubscription, Schema};
 use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
 use std::sync::Arc;
 
 pub async fn graphql_handler(
-    schema: Arc<Schema<Query, EmptyMutation, EmptySubscription>>,
+    schema: Arc<Schema<Query, Mutation, EmptySubscription>>,
     req: GraphQLRequest,
 ) -> GraphQLResponse {
     schema.execute(req.into_inner()).await.into()
diff --git a/src/graphql/types/jwt.rs b/src/graphql/types/jwt.rs
new file mode 100644
index 0000000..932f7fd
--- /dev/null
+++ b/src/graphql/types/jwt.rs
@@ -0,0 +1,79 @@
+use crate::errors::AppError;
+use async_graphql::{InputObject, SimpleObject};
+use chrono::{Duration, Local};
+use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
+use once_cell::sync::Lazy;
+use serde::{Deserialize, Serialize};
+
+struct Keys {
+    encoding: EncodingKey,
+    _decoding: DecodingKey,
+}
+
+static KEYS: Lazy<Keys> = Lazy::new(|| {
+    let secret = &crate::config::CONFIG.jwt_secret;
+    Keys::new(secret.as_bytes())
+});
+
+impl Keys {
+    fn new(secret: &[u8]) -> Self {
+        Self {
+            encoding: EncodingKey::from_secret(secret),
+            _decoding: DecodingKey::from_secret(secret),
+        }
+    }
+}
+
+/// Claims struct
+#[derive(Serialize, Deserialize)]
+pub struct Claims {
+    /// ID from the user model
+    pub user_id: i32,
+    /// Expiration timestamp
+    exp: usize,
+}
+
+impl Claims {
+    /// Create a new Claim using the `user_id` and the current timestamp + 2 days
+    pub fn new(user_id: i32) -> Self {
+        let expiration = Local::now() + Duration::days(2);
+
+        Self {
+            user_id,
+            exp: expiration.timestamp() as usize,
+        }
+    }
+
+    /// Returns the token as a string. If a token is not encoded, raises an
+    /// `AppError::TokenCreation`
+    pub fn get_token(&self) -> Result<String, AppError> {
+        let token = encode(&Header::default(), &self, &KEYS.encoding)
+            .map_err(|_| AppError::TokenCreation)?;
+
+        Ok(token)
+    }
+}
+
+#[derive(InputObject, Debug)]
+pub struct LoginCredentials {
+    pub email: String,
+    pub password: String,
+}
+
+/// Body used as response to login
+#[derive(Serialize, SimpleObject)]
+pub struct AuthBody {
+    /// Access token string
+    access_token: String,
+    /// "Bearer" string
+    token_type: String,
+}
+
+impl AuthBody {
+    pub fn new(access_token: String) -> Self {
+        Self {
+            access_token,
+            token_type: "Bearer".to_string(),
+        }
+    }
+}
diff --git a/src/graphql/types/mod.rs b/src/graphql/types/mod.rs
index 22d12a3..d7cdece 100644
--- a/src/graphql/types/mod.rs
+++ b/src/graphql/types/mod.rs
@@ -1 +1,2 @@
+pub mod jwt;
 pub mod user;
diff --git a/src/main.rs b/src/main.rs
index e87a7c4..04e2564 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8,7 +8,7 @@ mod state;
 use std::{net::SocketAddr, sync::Arc, time::Duration};
 
 use crate::config::CONFIG;
-use async_graphql::{EmptyMutation, EmptySubscription, Schema};
+use async_graphql::{EmptySubscription, Schema};
 use axum::{
     http::{header, Request},
     routing::post,
@@ -30,9 +30,13 @@ async fn create_app() -> Router {
         client: Arc::new(dbclient),
     };
 
-    let schema = Schema::build(graphql::query::Query, EmptyMutation, EmptySubscription)
-        .data(state.clone())
-        .finish();
+    let schema = Schema::build(
+        graphql::query::Query,
+        graphql::mutation::Mutation,
+        EmptySubscription,
+    )
+    .data(state.clone())
+    .finish();
     Router::new()
         .route(
             "/graphql",
-- 
cgit v1.2.3-18-g5258