Handle database-backed sessions.

This commit is contained in:
Rasmus Kaj 2018-08-19 02:23:20 +02:00
parent cb5bc07c7f
commit d1a0e83d20
14 changed files with 223 additions and 111 deletions

6
.rustfmt.toml Normal file
View File

@ -0,0 +1,6 @@
max_width = 78
reorder_imports = true
report_fixme = "Always"
# schema.rs is generated by diesel, don't format it.
ignore = [ "src/schema.rs" ]

View File

@ -18,3 +18,4 @@ dotenv = "0.13.0"
serde = "1.0.0"
serde_derive = "1.0.0"
bcrypt = "0.2.0"
rand = "0.5.5"

View File

@ -1 +1,2 @@
DROP TABLE sessions;
DROP TABLE users;

View File

@ -1,3 +1,5 @@
-- Create tables for users and sessions.
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR UNIQUE NOT NULL,
@ -6,3 +8,13 @@ CREATE TABLE users (
);
CREATE UNIQUE INDEX users_username_idx ON users (username);
CREATE TABLE sessions (
id SERIAL PRIMARY KEY,
cookie VARCHAR NOT NULL,
user_id INTEGER NOT NULL REFERENCES users (id)
-- TODO time created? time last accessed? both?
-- Other "nice to have" fields may be added here or reference by id
);
CREATE UNIQUE INDEX sessions_cookie_idx ON users (username);

View File

@ -1,4 +0,0 @@
max_width = 78
reorder_imports = true
report_fixme = "Always"

View File

@ -8,6 +8,7 @@ extern crate env_logger;
#[macro_use]
extern crate log;
extern crate mime;
extern crate rand;
#[macro_use]
extern crate serde_derive;
extern crate warp;
@ -15,18 +16,18 @@ extern crate warp;
mod models;
mod render_ructe;
mod schema;
mod session;
use diesel::insert_into;
use diesel::pg::PgConnection;
use diesel::prelude::*;
use diesel::r2d2::{ConnectionManager, Pool, PooledConnection};
use dotenv::dotenv;
use render_ructe::RenderRucte;
use session::{pg_pool, Session};
use std::env;
use std::io::{self, Write};
use std::time::{Duration, SystemTime};
use templates::statics::StaticFile;
use warp::http::{Response, StatusCode};
use warp::http::{header, Response, StatusCode};
use warp::{reject, Filter, Rejection, Reply};
/// Main program: Set up routes and start server.
@ -34,83 +35,100 @@ fn main() {
dotenv().ok();
env_logger::init();
let pool = pg_pool();
// setup the the connection pool to get a connection on each request
let pg =
warp::any()
.map(move || pool.clone())
.and_then(|pool: PgPool| match pool.get() {
Ok(conn) => Ok(conn),
Err(_) => Err(reject::server_error()),
});
// setup the the connection pool to get a session with a
// connection on each request
use warp::filters::cookie;
let pool =
pg_pool(&env::var("DATABASE_URL").expect("DATABASE_URL must be set"));
let pgsess = warp::any().and(cookie::optional("EXAUTH")).and_then(
move |key: Option<String>| {
let pool = pool.clone();
let key = key.as_ref().map(|s| &**s);
match pool.get() {
Ok(conn) => Ok(Session::from_key(conn, key)),
Err(_) => {
error!("Failed to get a db connection");
Err(reject::server_error())
}
}
},
);
let s = move || pgsess.clone();
use warp::{body, get2 as get, index, path, post2 as post};
let login_routes = path("login").and(index()).and(
get()
.and_then(login_form)
.or(post().and(pg.clone()).and(body::form()).and_then(do_login)),
);
let signup_routes = path("signup").and(index()).and(
get()
.and_then(signup_form)
.or(post().and(pg).and(body::form()).and_then(do_signup)),
);
let routes = get()
.and(
warp::index()
.and_then(home_page)
.or(path("static").and(path::param()).and_then(static_file))
.or(path("bad").and_then(bad_handler)),
).or(login_routes)
.or(signup_routes)
.recover(customize_error);
let static_routes = get()
.and(path("static"))
.and(path::param())
.and_then(static_file);
let routes = warp::any()
.and(static_routes)
.or(get().and(
(s().and(index()).and_then(home_page))
.or(s().and(path("login")).and(index()).and_then(login_form))
.or(s()
.and(path("signup"))
.and(index())
.and_then(signup_form)),
)).or(post().and(
(s().and(path("login")).and(body::form()).and_then(do_login)).or(
s().and(path("signup"))
.and(body::form())
.and_then(do_signup),
),
)).recover(customize_error);
warp::serve(routes).run(([127, 0, 0, 1], 3030));
}
type PgPool = Pool<ConnectionManager<PgConnection>>;
type PooledPg = PooledConnection<ConnectionManager<PgConnection>>;
fn pg_pool() -> PgPool {
let database_url =
env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let manager = ConnectionManager::<PgConnection>::new(database_url);
Pool::new(manager).expect("Postgres connection pool could not be created")
}
/// Render a login form.
fn login_form() -> Result<impl Reply, Rejection> {
Response::builder().html(|o| templates::login(o, None, None))
fn login_form(session: Session) -> Result<impl Reply, Rejection> {
Response::builder().html(|o| templates::login(o, &session, None, None))
}
/// Verify a login attempt.
/// If the credentials in the LoginForm are correct, redirect to the home page.
///
/// If the credentials in the LoginForm are correct, redirect to the
/// home page.
/// Otherwise, show the login form again, but with a message.
fn do_login(db: PooledPg, form: LoginForm) -> Result<impl Reply, Rejection> {
fn do_login(
session: Session,
form: LoginForm,
) -> Result<impl Reply, Rejection> {
use schema::users::dsl::*;
let authenticated = users
.filter(username.eq(&form.user))
.select(password)
.first::<String>(&db)
.select((id, password))
.first(session.db())
.map_err(|e| {
error!("Failed to load hash for {:?}: {:?}", form.user, e);
()
}).and_then(|hash: String| {
bcrypt::verify(&form.password, &hash).map_err(|e| {
error!("Failed to verify hash for {:?}: {:?}", form.user, e)
})
}).unwrap_or(false);
eprintln!("Database result: {:?}", authenticated);
if authenticated {
}).and_then(|(userid, hash): (i32, String)| {
match bcrypt::verify(&form.password, &hash) {
Ok(true) => Ok(userid),
Ok(false) => Err(()),
Err(e) => {
error!("Verify failed for {:?}: {:?}", form.user, e);
Err(())
}
}
});
if let Ok(userid) = authenticated {
info!("User {} ({}) authenticated", userid, form.user);
let secret = session.create(userid).map_err(|e| {
error!("Failed to create session: {}", e);
reject::server_error()
})?;
Response::builder()
.status(StatusCode::FOUND)
.header("location", "/")
// TODO: Set a session cookie?
.body(b"".to_vec())
.header(header::LOCATION, "/")
.header(
header::SET_COOKIE,
format!("EXAUTH={}; SameSite=Strict; HttpOpnly", secret),
).body(b"".to_vec())
.map_err(|_| reject::server_error()) // TODO This seems ugly?
} else {
Response::builder().html(|o| {
templates::login(o, None, Some("Authentication failed"))
templates::login(o, &session, None, Some("Authentication failed"))
})
}
}
@ -123,16 +141,14 @@ struct LoginForm {
password: String,
}
/// Render a login form.
fn signup_form() -> Result<impl Reply, Rejection> {
Response::builder().html(|o| templates::signup(o, None))
/// Render a signup form.
fn signup_form(session: Session) -> Result<impl Reply, Rejection> {
Response::builder().html(|o| templates::signup(o, &session, None))
}
/// Verify a login attempt.
/// If the credentials in the LoginForm are correct, redirect to the home page.
/// Otherwise, show the login form again, but with a message.
/// Handle a submitted signup form.
fn do_signup(
db: PooledPg,
session: Session,
form: SignupForm,
) -> Result<impl Reply, Rejection> {
let result = form
@ -149,21 +165,20 @@ fn do_signup(
username.eq(form.user),
realname.eq(form.realname),
password.eq(&hash),
)).execute(&db)
)).execute(session.db())
.map_err(|e| format!("Oops: {}", e))
});
match result {
Ok(_) => {
Response::builder()
.status(StatusCode::FOUND)
.header("location", "/")
.header(header::LOCATION, "/")
// TODO: Set a session cookie?
.body(b"".to_vec())
.map_err(|_| reject::server_error()) // TODO This seems ugly?
}
Err(msg) => {
Response::builder().html(|o| templates::signup(o, Some(&msg)))
}
Err(msg) => Response::builder()
.html(|o| templates::signup(o, &session, Some(&msg))),
}
}
@ -191,25 +206,22 @@ impl SignupForm {
}
/// Home page handler; just render a template with some arguments.
fn home_page() -> Result<impl Reply, Rejection> {
fn home_page(session: Session) -> Result<impl Reply, Rejection> {
info!("Visiting home_page as {:?}", session.user());
Response::builder().html(|o| {
templates::page(o, &[("first", 3), ("second", 7), ("third", 2)])
templates::page(o, &session, &[("first", 3), ("second", 7)])
})
}
/// A handler that always gives a server error.
fn bad_handler() -> Result<StatusCode, Rejection> {
Err(reject::server_error())
}
/// This method can be used as a "template tag", i.e. a method that
/// can be called directly from a template.
fn footer(out: &mut Write) -> io::Result<()> {
templates::footer(
out,
&[
("ructe", "https://crates.io/crates/ructe"),
("warp", "https://crates.io/crates/warp"),
("diesel", "https://diesel.rs/"),
("ructe", "https://crates.io/crates/ructe"),
],
)
}

View File

@ -1 +1,8 @@
// No models here yet.
use schema::users;
#[derive(Debug, Identifiable, Queryable)]
pub struct User {
pub id: i32,
pub username: String,
pub realname: String,
}

View File

@ -1,3 +1,11 @@
table! {
sessions (id) {
id -> Int4,
cookie -> Varchar,
user_id -> Int4,
}
}
table! {
users (id) {
id -> Int4,
@ -6,3 +14,10 @@ table! {
password -> Varchar,
}
}
joinable!(sessions -> users (user_id));
allow_tables_to_appear_in_same_query!(
sessions,
users,
);

67
src/session.rs Normal file
View File

@ -0,0 +1,67 @@
use diesel::insert_into;
use diesel::pg::PgConnection;
use diesel::prelude::*;
use diesel::r2d2::{ConnectionManager, Pool, PooledConnection};
use diesel::result::Error;
use models::User;
use rand::distributions::Alphanumeric;
use rand::thread_rng;
use rand::Rng;
type PooledPg = PooledConnection<ConnectionManager<PgConnection>>;
type PgPool = Pool<ConnectionManager<PgConnection>>;
/// A Session object is sent to most handler methods.
///
/// The content of the session object is application specific.
/// My session contains a session pool for the database and an
/// optional user (if logged in).
/// It may also contain pools to other backend servers (e.g. memcache,
/// redis, or application specific services) and/or other temporary
/// user data (e.g. a shopping cart in a web shop).
pub struct Session {
db: PooledPg,
user: Option<User>,
}
impl Session {
pub fn from_key(db: PooledPg, sessionkey: Option<&str>) -> Self {
use schema::sessions::dsl as s;
use schema::users::dsl as u;
let user = sessionkey.and_then(|sessionkey| {
u::users
.select((u::id, u::username, u::realname))
.inner_join(s::sessions)
.filter(s::cookie.eq(&sessionkey))
.first::<User>(&db)
.ok()
});
info!("Got: {:?}", user);
Session { db, user }
}
pub fn create(&self, userid: i32) -> Result<String, Error> {
let secret_cookie = random_key(48);
use schema::sessions::dsl::*;
insert_into(sessions)
.values((user_id.eq(userid), cookie.eq(&secret_cookie)))
.execute(self.db())?;
Ok(secret_cookie)
}
pub fn user(&self) -> Option<&User> {
self.user.as_ref()
}
pub fn db(&self) -> &PgConnection {
&self.db
}
}
fn random_key(len: usize) -> String {
let mut rng = thread_rng();
rng.sample_iter(&Alphanumeric).take(len).collect()
}
// TODO Not public
pub fn pg_pool(database_url: &str) -> PgPool {
let manager = ConnectionManager::<PgConnection>::new(database_url);
Pool::new(manager).expect("Postgres connection pool could not be created")
}

View File

@ -1,14 +1,11 @@
@(frameworks: &[(&str, &str)])
<footer>
@if let Some(((last_name, last_href), prev)) = frameworks.split_last() {
Made with
@if let Some(((last_name, last_href), prev)) = prev.split_last() {
@for (name, href) in prev {
<a href="@href">@name</a>,
}
<a href="@last_href">@last_name</a> and
}
<a href="@last_href">@last_name</a>.
}
<span>@if let Some(((last_name, last_href), prev)) = frameworks.split_last()
{Made with
@if let Some(((last_name, last_href), prev)) = prev.split_last()
{@for (name, href) in prev {<a href="@href">@name</a>, }
<a href="@last_href">@last_name</a> and }<a href="@last_href">@last_name</a>.}
</span>
<span>Sample app @env!("CARGO_PKG_VERSION").</span>
</footer>

View File

@ -1,8 +1,9 @@
@use templates::page_base;
@use Session;
@(next: Option<String>, message: Option<&str>)
@(session: &Session, next: Option<String>, message: Option<&str>)
@:page_base("login", {
@:page_base(session, "login", {
<form action="/login" method="post">
@if let Some(message) = message {<p>@message</p>}
<p><label for="user">User:</label>

View File

@ -1,8 +1,9 @@
@use templates::page_base;
@use Session;
@(paras: &[(&str, usize)])
@(session: &Session, paras: &[(&str, usize)])
@:page_base("Example", {
@:page_base(session, "Example", {
<p>This is a simple sample page.</p>

View File

@ -1,6 +1,7 @@
@use templates::statics::style_css;
@use {footer, Session};
@(title: &str, content: Content)
@(session: &Session, title: &str, content: Content)
<!doctype html>
<html>
@ -13,20 +14,14 @@
<body>
<header>
<span>Example app</span>
@*
@if let Some(u) = MySession::get_user(state) {<span class="user">@u (<a href="/logout">log out</a>)</span>}
else { *@
<span class="user">(<a href="/login">log in</a> or
<a href="/signup">sign up</a>)</span>
@* } *@
@if let Some(u) = session.user() {<span class="user">@u.username (<a href="/logout">log out</a>)</span>}
else { <span class="user">(<a href="/login">log in</a> or <a href="/signup">sign up</a>)</span> }
</header>
<main>
<h1>@title</h1>
@:content()
</main>
<footer>
<p>Sample login app @env!("CARGO_PKG_VERSION").</p>
</footer>
@:footer()
</body>
</html>

View File

@ -1,8 +1,9 @@
@use templates::page_base;
@use Session;
@(message: Option<&str>)
@(session: &Session, message: Option<&str>)
@:page_base("login", {
@:page_base(session, "Sign up", {
<form action="" method="post">
@if let Some(message) = message {<p>@message</p>}
<p><label for="user">User:</label>
@ -12,7 +13,7 @@
<p><label for="password">Password:</label>
<input id="password" name="password" type="password"></p>
<p><span>[<a href="/">Cancel</a>]</span>
<input type="submit" value="Log in">
<input type="submit" value="Sign up">
</p>
</form>
})