summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock35
-rw-r--r--Cargo.toml2
-rw-r--r--src/auth.rs25
-rw-r--r--src/main.rs9
-rw-r--r--src/routes/auth.rs104
-rw-r--r--static/login.html18
-rw-r--r--static/login.js38
-rw-r--r--static/main.js3
8 files changed, 196 insertions, 38 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 558395b..334c8bf 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -85,6 +85,28 @@ dependencies = [
]
[[package]]
+name = "axum-extra"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970"
+dependencies = [
+ "axum",
+ "axum-core",
+ "bytes",
+ "cookie",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -171,11 +193,13 @@ version = "0.1.0"
dependencies = [
"argon2",
"axum",
+ "axum-extra",
"jsonwebtoken",
"r2d2",
"r2d2_sqlite",
"serde",
"serde_json",
+ "time",
"tokio",
"tower-http",
]
@@ -187,6 +211,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
+name = "cookie"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
+[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 5b106f3..466085c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,10 +6,12 @@ edition = "2024"
[dependencies]
argon2 = { version = "0.5.3", features = ["std"] }
axum = "0.8.9"
+axum-extra = { version = "0.12.6", features = ["cookie"] }
jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] }
r2d2 = "0.8.10"
r2d2_sqlite = "0.34.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
+time = "0.3.47"
tokio = { version = "1.52.2", features = ["macros", "rt-multi-thread"] }
tower-http = { version = "0.6.10", features = ["fs"] }
diff --git a/src/auth.rs b/src/auth.rs
index 90ce4a5..a621756 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -1,3 +1,5 @@
+use axum::{extract::FromRequestParts, http::{StatusCode, request::Parts}};
+use serde::Serialize;
use argon2::{Argon2, PasswordHash, PasswordVerifier, password_hash::{
Error, PasswordHasher, SaltString, rand_core::OsRng
}};
@@ -14,6 +16,29 @@ pub fn check_password(password: &str, password_hash: &str) -> Result<bool, Error
Ok(argon2.verify_password(password.as_bytes(), &hash).is_ok())
}
+#[derive(Serialize)]
+pub struct Claims {
+ pub sub: String,
+ pub exp: usize,
+ pub iat: usize,
+ pub is_admin: bool,
+}
+
+//pub fn create_jwt(email: &str, is_admin: bool) -> Result
+
+pub struct AuthUser(pub Claims);
+
+impl<S: Send + Sync> FromRequestParts<S> for AuthUser {
+ type Rejection = StatusCode;
+
+ async fn from_request_parts(
+ parts: &mut Parts,
+ state: &S,
+ ) -> Result<Self, Self::Rejection> {
+ todo!();
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/main.rs b/src/main.rs
index 511a8f2..5629578 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,4 @@
-use std::{env, fs};
+use std::env;
mod database;
mod routes;
@@ -19,7 +19,7 @@ use crate::database::Database;
#[derive(Clone)]
struct AppState {
- api_key: EncodingKey,
+ secret: String,
database: Database,
}
@@ -31,14 +31,11 @@ async fn main() {
return;
};
- let api_key = EncodingKey::from_secret(secret.as_ref());
-
-
let database = Database::new_in_memory().unwrap();
database.initialize().unwrap();
let state = AppState {
- api_key: api_key.to_owned(),
+ secret: secret,
database: database,
};
diff --git a/src/routes/auth.rs b/src/routes/auth.rs
index 64ada2b..4fb9124 100644
--- a/src/routes/auth.rs
+++ b/src/routes/auth.rs
@@ -1,34 +1,72 @@
use std::time::{SystemTime, UNIX_EPOCH};
-use axum::extract::{Json, State};
+use axum::extract::{Json, State, FromRequestParts};
use axum::response::IntoResponse;
-use jsonwebtoken::{encode, Header};
+use axum::http::{StatusCode, request::Parts};
+use axum_extra::extract::CookieJar;
+use axum_extra::extract::cookie::{Cookie, SameSite};
+use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize};
+use time::Duration;
+
+use argon2::{Argon2, PasswordHash, PasswordVerifier, password_hash::{
+ Error, PasswordHasher, SaltString, rand_core::OsRng
+}};
use crate::AppState;
-use crate::auth::{check_password};
-use super::errors::RouteError;
+use crate::routes::errors::RouteError;
-#[derive(Deserialize)]
-pub struct LoginRequest {
- email: String,
- password: String,
+#[derive(Serialize, Deserialize)]
+pub struct Claims {
+ pub sub: String,
+ pub exp: usize,
+ pub iat: usize,
+ pub is_admin: bool,
}
-#[derive(Serialize)]
-struct LoginResponse {
- token: String,
+pub struct AuthUser(pub Claims);
+
+impl FromRequestParts<AppState> for AuthUser {
+ type Rejection = RouteError;
+
+ async fn from_request_parts(
+ parts: &mut Parts,
+ state: &AppState,
+ ) -> Result<Self, Self::Rejection> {
+ let jar = CookieJar::from_request_parts(parts, state).await.unwrap();
+
+ let token = jar.get("token")
+ .map(|c| c.value().to_string())
+ .ok_or(RouteError::AuthorizationFailure())?;
+
+ let key = DecodingKey::from_secret(state.secret.as_ref());
+ let data = decode::<Claims>(token, &key, &Validation::default())
+ .map_err(|_| RouteError::AuthorizationFailure())?;
+
+ Ok(AuthUser(data.claims))
+ }
}
-#[derive(Serialize)]
-struct JWTClaims {
- sub: String,
- exp: usize,
- iat: usize,
- is_admin: bool,
+pub fn hash_password(password: &str) -> Result<String, Error> {
+ let argon2 = Argon2::default();
+ let salt = SaltString::generate(&mut OsRng);
+ Ok(argon2.hash_password(password.as_bytes(), &salt)?.to_string())
+}
+
+pub fn check_password(password: &str, password_hash: &str) -> Result<bool, Error> {
+ let argon2 = Argon2::default();
+ let hash = PasswordHash::new(password_hash)?;
+ Ok(argon2.verify_password(password.as_bytes(), &hash).is_ok())
+}
+
+#[derive(Deserialize)]
+pub struct LoginRequest {
+ email: String,
+ password: String,
}
pub async fn login(
+ jar: CookieJar,
State(state): State<AppState>,
Json(request): Json<LoginRequest>
) -> Result<impl IntoResponse, RouteError> {
@@ -50,17 +88,39 @@ pub async fn login(
.map_err(|_| RouteError::Internal("failed to access system clock".into()))?
.as_secs() as usize;
- let claims = JWTClaims {
- sub: user.email().to_owned(),
+ let claims = Claims {
+ sub: user.email().to_string(),
iat: now,
exp: now + 60 * 60 * 24,
is_admin: false
};
-
- let token= encode(&Header::default(), &claims, &state.api_key)
+
+ let key = EncodingKey::from_secret(state.secret.as_ref());
+ let token = encode(&Header::default(), &claims, &key)
.map_err(|e| RouteError::Internal(format!("failed to encode jwt: {e}")))?;
- Ok(Json(LoginResponse { token }))
+ let cookie = Cookie::build(("token", token))
+ .path("/")
+ .max_age(Duration::hours(24))
+ .same_site(SameSite::Strict)
+ .http_only(true)
+ .build();
+
+ Ok(jar.add(cookie))
}
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_password_hashing() {
+ let passwords = vec!["password", "test1", "random"];
+
+ for password in &passwords {
+ let hash = hash_password(password).unwrap();
+ assert!(check_password(password, &hash).unwrap());
+ }
+ }
+}
diff --git a/static/login.html b/static/login.html
index 37035dc..c6cd992 100644
--- a/static/login.html
+++ b/static/login.html
@@ -5,21 +5,25 @@
<meta charset="utf-8" />
<title>Code Golf Login</title>
<link rel="stylesheet" href="default.css">
- <script type="text/javascript" src="main.js"></script>
+ <script type="text/javascript" src="login.js"></script>
</head>
- <body>
+ <body onload="init()">
<div id="layout">
<div id="container">
<div id="content">
<h1>C&amp;! Code Golf Leaderboard</h1>
<h2>Login</h2>
- <form id="login-form" action="" method="get" onsubmit="login()">
- <label for="login-email">Email</label><br>
- <input type="text" id="login-email" name="login-email">
+ <form id="login-form" method="POST" action="/login">
+ <div id="error" hidden>
+ <span id="error-message" style="color: red"></span>
+ <br><br>
+ </div>
+ <label for="email">Email</label><br>
+ <input type="text" id="login-email" name="email">
<br><br>
- <label for="login-password">Password</label><br>
- <input type="password" id="login-password" name="login-password">
+ <label for="password">Password</label><br>
+ <input type="password" id="login-password" name="password">
<br><br>
<input type="submit" value="Login">
</form>
diff --git a/static/login.js b/static/login.js
new file mode 100644
index 0000000..ee135f8
--- /dev/null
+++ b/static/login.js
@@ -0,0 +1,38 @@
+
+function display_error(message) {
+ document.getElementById("error-message").innerHTML = `Error: ${message}`;
+ document.getElementById("error").hidden = false;
+}
+
+function init() {
+ const form = document.getElementById("login-form");
+ form.addEventListener("submit", async (e) => {
+ e.preventDefault();
+
+ const body = {}
+ new FormData(form).forEach((value, key) => body[key] = value);
+
+ try {
+ console.log();
+
+ const res = await fetch("/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify(body)
+ });
+
+ if (!res.ok) {
+ const error = await res.json();
+ display_error(error.error);
+ return;
+ }
+
+ //const result = await res.json();
+
+ } catch (err) {
+ console.log(err);
+ //display_error("network error");
+ }
+ });
+}
diff --git a/static/main.js b/static/main.js
index 2bde100..f18cbef 100644
--- a/static/main.js
+++ b/static/main.js
@@ -41,6 +41,3 @@ async function on_load() {
}
-async function login() {
- console.log("login pressed");
-}