Handle database-backed sessions.
This commit is contained in:
parent
cb5bc07c7f
commit
d1a0e83d20
|
@ -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" ]
|
|
@ -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"
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
DROP TABLE sessions;
|
||||
DROP TABLE users;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
max_width = 78
|
||||
|
||||
reorder_imports = true
|
||||
report_fixme = "Always"
|
170
src/main.rs
170
src/main.rs
|
@ -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"),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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")
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue