Login and logout

This commit is contained in:
Greg Shuflin 2025-02-01 23:01:33 -08:00
parent 868df22ea7
commit a1a0a04bd8
7 changed files with 288 additions and 6 deletions

89
Cargo.lock generated
View File

@ -17,6 +17,41 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.11" version = "0.8.11"
@ -351,7 +386,13 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [ dependencies = [
"aes-gcm",
"base64 0.22.1",
"hkdf",
"percent-encoding", "percent-encoding",
"rand",
"sha2",
"subtle",
"time", "time",
"version_check", "version_check",
] ]
@ -436,9 +477,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core",
"typenum", "typenum",
] ]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.20.10" version = "0.20.10"
@ -853,6 +904,16 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]] [[package]]
name = "gimli" name = "gimli"
version = "0.31.1" version = "0.31.1"
@ -1648,6 +1709,12 @@ version = "1.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "overload" name = "overload"
version = "0.1.1" version = "0.1.1"
@ -1863,6 +1930,18 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2"
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.0" version = "0.2.0"
@ -3174,6 +3253,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"

View File

@ -7,7 +7,7 @@ edition = "2021"
argon2 = "0.5.3" argon2 = "0.5.3"
atom_syndication = "0.12.6" atom_syndication = "0.12.6"
chrono = { version = "0.4.34", features = ["serde"] } chrono = { version = "0.4.34", features = ["serde"] }
rocket = { version = "0.5.1", features = ["json"] } rocket = { version = "0.5.1", features = ["json", "secrets"] }
rocket_db_pools = { version = "0.2.0", features = ["sqlx_sqlite"] } rocket_db_pools = { version = "0.2.0", features = ["sqlx_sqlite"] }
rocket_dyn_templates = { version = "0.2.0", features = ["tera"] } rocket_dyn_templates = { version = "0.2.0", features = ["tera"] }
rss = "2.0.11" rss = "2.0.11"

View File

@ -1,2 +1,4 @@
[default.databases.rss_data] [default.databases.rss_data]
url = "sqlite:data.sqlite" url = "sqlite:data.sqlite"
secret_key = "MHSePvm1msyOkYuJ7u+MtyJYCzgdHCS7QNvrk9ts+rI="

View File

@ -7,6 +7,8 @@ use rocket::fs::FileServer;
use rocket::serde::{json::Json, Serialize}; use rocket::serde::{json::Json, Serialize};
use rocket_db_pools::{sqlx, Database}; use rocket_db_pools::{sqlx, Database};
use rocket_dyn_templates::{context, Template}; use rocket_dyn_templates::{context, Template};
use rocket::response::Redirect;
use user::AuthenticatedUser;
#[derive(Database)] #[derive(Database)]
#[database("rss_data")] #[database("rss_data")]
@ -28,10 +30,15 @@ fn message() -> Json<Message> {
} }
#[get("/")] #[get("/")]
fn index() -> Template { fn index(_user: AuthenticatedUser) -> Template {
Template::render("index", context! {}) Template::render("index", context! {})
} }
#[get("/", rank = 2)]
fn index_redirect() -> Redirect {
Redirect::to(uri!(login))
}
#[get("/login")] #[get("/login")]
fn login() -> Template { fn login() -> Template {
Template::render("login", context! {}) Template::render("login", context! {})
@ -44,11 +51,14 @@ fn rocket() -> _ {
"/", "/",
routes![ routes![
index, index,
index_redirect,
message, message,
login, login,
user::create_user, user::create_user,
user::get_users, user::get_users,
user::delete_user user::delete_user,
user::login,
user::logout
], ],
) )
.mount("/static", FileServer::from("static")) .mount("/static", FileServer::from("static"))

View File

@ -1,5 +1,5 @@
use chrono; use chrono;
use rocket::http::Status; use rocket::http::{Cookie, CookieJar, Status};
use rocket::serde::{json::Json, Deserialize, Serialize}; use rocket::serde::{json::Json, Deserialize, Serialize};
use rocket_db_pools::Connection; use rocket_db_pools::Connection;
use uuid::Uuid; use uuid::Uuid;
@ -40,6 +40,20 @@ pub struct NewUser {
pub display_name: Option<String>, pub display_name: Option<String>,
} }
#[derive(Debug, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct LoginCredentials {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize)]
#[serde(crate = "rocket::serde")]
pub struct LoginResponse {
pub user_id: Uuid,
pub username: String,
}
#[post("/users", data = "<new_user>")] #[post("/users", data = "<new_user>")]
pub async fn create_user( pub async fn create_user(
mut db: Connection<Db>, mut db: Connection<Db>,
@ -146,4 +160,82 @@ pub async fn delete_user(mut db: Connection<Db>, user_id: &str) -> Status {
Status::InternalServerError Status::InternalServerError
} }
} }
} }
#[post("/login", data = "<credentials>")]
pub async fn login(
mut db: Connection<Db>,
credentials: Json<LoginCredentials>,
cookies: &CookieJar<'_>,
) -> Result<Json<LoginResponse>, Status> {
let creds = credentials.into_inner();
// Find user by username
let user = sqlx::query!(
r#"
SELECT
id as "id: String",
username,
password_hash
FROM users
WHERE username = ?
"#,
creds.username
)
.fetch_optional(&mut **db)
.await
.map_err(|e| {
eprintln!("Database error: {}", e);
Status::InternalServerError
})?;
let user = match user {
Some(user) => user,
None => return Err(Status::Unauthorized),
};
// Verify password
let valid = bcrypt::verify(creds.password.as_bytes(), &user.password_hash)
.map_err(|_| Status::InternalServerError)?;
if !valid {
return Err(Status::Unauthorized);
}
// Set session cookie
let user_id = Uuid::parse_str(&user.id).map_err(|_| Status::InternalServerError)?;
cookies.add_private(Cookie::new("user_id", user_id.to_string()));
Ok(Json(LoginResponse {
user_id,
username: user.username,
}))
}
#[post("/logout")]
pub fn logout(cookies: &CookieJar<'_>) -> Status {
cookies.remove_private(Cookie::named("user_id"));
Status::NoContent
}
// Add auth guard
pub struct AuthenticatedUser {
pub user_id: Uuid,
}
#[rocket::async_trait]
impl<'r> rocket::request::FromRequest<'r> for AuthenticatedUser {
type Error = ();
async fn from_request(request: &'r rocket::Request<'_>) -> rocket::request::Outcome<Self, Self::Error> {
let cookies = request.cookies();
if let Some(user_id_cookie) = cookies.get_private("user_id") {
if let Ok(user_id) = Uuid::parse_str(user_id_cookie.value()) {
return rocket::request::Outcome::Success(AuthenticatedUser { user_id });
}
}
rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, ()))
}
}

View File

@ -5,8 +5,49 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RSS Reader</title> <title>RSS Reader</title>
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<style>
.top-bar {
background-color: #333;
color: white;
padding: 0.5rem 1rem;
display: flex;
justify-content: flex-end;
align-items: center;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
}
.logout-button {
background-color: #dc3545;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.logout-button:hover {
background-color: #c82333;
}
/* Adjust main content to account for top bar */
.with-sidebar {
padding-top: 3rem;
}
.sidebar {
top: 3rem;
}
</style>
</head> </head>
<body class="with-sidebar"> <body class="with-sidebar">
<div class="top-bar">
<button class="logout-button" id="logoutButton">Logout</button>
</div>
<div class="sidebar"> <div class="sidebar">
<h2>Navigation</h2> <h2>Navigation</h2>
<ul> <ul>
@ -19,5 +60,21 @@
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p> <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p> <p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
</div> </div>
<script>
document.getElementById('logoutButton').addEventListener('click', async () => {
try {
const response = await fetch('/logout', {
method: 'POST',
});
if (response.ok) {
window.location.href = '/login';
}
} catch (error) {
console.error('Logout failed:', error);
}
});
</script>
</body> </body>
</html> </html>

View File

@ -7,7 +7,7 @@
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
</head> </head>
<div class="login-container"> <div class="login-container">
<form class="login-form"> <form class="login-form" id="loginForm">
<h1>Login</h1> <h1>Login</h1>
<div class="form-group"> <div class="form-group">
<label for="username">Username</label> <label for="username">Username</label>
@ -17,8 +17,40 @@
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" name="password" required> <input type="password" id="password" name="password" required>
</div> </div>
<div class="error-message" id="errorMessage" style="display: none; color: red; margin-bottom: 10px;"></div>
<button type="submit">Log In</button> <button type="submit">Log In</button>
</form> </form>
</div> </div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const errorMessage = document.getElementById('errorMessage');
errorMessage.style.display = 'none';
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
if (response.ok) {
window.location.href = '/';
} else {
errorMessage.textContent = 'Invalid username or password';
errorMessage.style.display = 'block';
}
} catch (error) {
errorMessage.textContent = 'An error occurred. Please try again.';
errorMessage.style.display = 'block';
}
});
</script>
</body> </body>
</html> </html>