diff --git a/ops/migrations/20190825172701_email.js b/ops/migrations/20190825172701_email.js index 6f434893..3c69f92b 100644 --- a/ops/migrations/20190825172701_email.js +++ b/ops/migrations/20190825172701_email.js @@ -24,6 +24,14 @@ exports.up = async knex => { .notNullable() .index(); + table.string('recover_token', 64) + .notNullable() + .index(); + + table.timestamp('recover_token_expiry') + .notNullable() + .defaultTo(knex.fn.now()); + table.bool('confirmed') .notNullable() .defaultTo(false); diff --git a/server/src/account.rs b/server/src/account.rs index 562cae11..a33b6b02 100644 --- a/server/src/account.rs +++ b/server/src/account.rs @@ -78,7 +78,7 @@ pub fn select_name(db: &Db, name: &String) -> Result { Account::try_from(row) } -pub fn from_token(db: &Db, token: String) -> Result { +pub fn from_token(db: &Db, token: &String) -> Result { let query = " SELECT id, name, subscribed, balance FROM accounts @@ -87,7 +87,7 @@ pub fn from_token(db: &Db, token: String) -> Result { "; let result = db - .query(query, &[&token])?; + .query(query, &[token])?; let row = result.iter().next() .ok_or(err_msg("invalid token"))?; diff --git a/server/src/http.rs b/server/src/http.rs index af157978..592a06d0 100644 --- a/server/src/http.rs +++ b/server/src/http.rs @@ -19,6 +19,7 @@ use lettre::{SendableEmail, SmtpClient, SmtpTransport, Transport}; use acp; use account; use mail; +use mail::Mail; use pg::PgPool; use payments::{stripe}; @@ -56,19 +57,22 @@ pub enum MnmlHttpError { } impl From for MnmlHttpError { - fn from(_err: bcrypt::BcryptError) -> Self { + fn from(err: bcrypt::BcryptError) -> Self { + warn!("{:?}", err); MnmlHttpError::ServerError } } impl From for MnmlHttpError { - fn from(_err: postgres::Error) -> Self { + fn from(err: postgres::Error) -> Self { + warn!("{:?}", err); MnmlHttpError::DbError } } impl From for MnmlHttpError { - fn from(_err: r2d2::Error) -> Self { + fn from(err: r2d2::Error) -> Self { + warn!("{:?}", err); MnmlHttpError::DbError } } @@ -138,9 +142,9 @@ impl BeforeMiddleware for AuthMiddleware { // got auth token if cookie.name() == TOKEN_HEADER { - match account::from_token(&db, cookie.value().to_string()) { + match account::from_token(&db, &cookie.value().to_string()) { Ok(a) => req.extensions.insert::(a), - Err(_) => return Err(IronError::from(MnmlHttpError::TokenDoesNotMatch)), + Err(_) => return Err(MnmlHttpError::TokenDoesNotMatch.into()), }; } } @@ -200,7 +204,7 @@ fn register(req: &mut Request) -> IronResult { let state = req.get::>().unwrap(); let params = match req.get::>() { Ok(Some(b)) => b, - _ => return Err(IronError::from(MnmlHttpError::BadRequest)), + _ => return Err(MnmlHttpError::BadRequest.into()), }; let db = state.pool.get().or(Err(MnmlHttpError::DbError))?; @@ -213,7 +217,7 @@ fn register(req: &mut Request) -> IronResult { }, Err(e) => { warn!("{:?}", e); - Err(IronError::from(e)) + Err(e.into()) } } } @@ -228,7 +232,7 @@ fn login(req: &mut Request) -> IronResult { let state = req.get::>().unwrap(); let params = match req.get::>() { Ok(Some(b)) => b, - _ => return Err(IronError::from(MnmlHttpError::BadRequest)), + _ => return Err(MnmlHttpError::BadRequest.into()), }; let db = state.pool.get().or(Err(MnmlHttpError::DbError))?; @@ -242,7 +246,7 @@ fn login(req: &mut Request) -> IronResult { }, Err(e) => { warn!("{:?}", e); - Err(IronError::from(e)) + Err(e.into()) } } } @@ -261,12 +265,95 @@ fn logout(req: &mut Request) -> IronResult { let mut res = json_response(status::Ok, Json::Message("logged out".to_string())); res.headers.set(SetCookie(vec![AUTH_CLEAR.to_string()])); Ok(res) - }, - None => Err(IronError::from(MnmlHttpError::Unauthorized)), + None => Err(MnmlHttpError::Unauthorized.into()), } } +fn recover_set(req: &mut Request) -> IronResult { + let state = req.get::>().unwrap(); + let params = match req.get::>() { + Ok(Some(b)) => b, + _ => return Err(MnmlHttpError::BadRequest.into()), + }; + + let db = state.pool.get().or(Err(MnmlHttpError::DbError))?; + let mut tx = db.transaction().or(Err(MnmlHttpError::DbError))?; + + let user_email = match mail::select(&db, ¶ms.email) { + Ok(e) => match e.confirmed { + true => e, + false => return Ok(json_response(status::NotFound, + Json::Error("your email is not confirmed.\nplease contact support at humans@mnml.gg".to_string()))), + }, + Err(_e) => return Ok(json_response(status::NotFound, + Json::Error("email not registered.\nplease contact support at humans@mnml.gg".to_string()))), + }; + + let account = account::select(&db, user_email.account) + .or(Err(MnmlHttpError::NotFound))?; + + let token = mail::set_recovery(&mut tx, &user_email.email) + .or(Err(MnmlHttpError::ServerError))?; + + let app_mailer = req.get::>().unwrap(); + let mut lock = app_mailer.lock().unwrap(); + let message = Mail::Recover { email: user_email.email.clone(), name: account.name.clone(), token }; + + let send = match mail::send_mail(&mut lock.mailer, message) { + Ok(send) => send, + Err(e) => { + warn!("{:?}", e); + return Err(MnmlHttpError::ServerError.into()); + } + }; + + tx.commit().or(Err(MnmlHttpError::ServerError))?; + + info!("recovery email sent send={:?} account={:?} email={:?}", send, account, user_email.email); + Ok(json_response(status::Ok, Json::Message("recovery email sent. check your mailbox for access".to_string()))) +} + +fn recover(req: &mut Request) -> IronResult { + let state = req.get::>().unwrap(); + let db = state.pool.get().or(Err(MnmlHttpError::DbError))?; + let mut tx = db.transaction().or(Err(MnmlHttpError::DbError))?; + let token = match req.get_ref::() { + Ok(ref hashmap) => { + match hashmap.get("recover_token") { + Some(t) => &t[0], + None => return Err(MnmlHttpError::BadRequest.into()), + } + }, + Err(_) => return Err(MnmlHttpError::BadRequest.into()), + }; + + let user_email = match mail::get_recovery(&mut tx, &token.to_string()) { + Ok(a) => a, + Err(_) => return Err(MnmlHttpError::Unauthorized.into()), + }; + + let token = account::new_token(&mut tx, user_email.account) + .or(Err(MnmlHttpError::ServerError))?; + + let account = account::from_token(&db, &token) + .or(Err(MnmlHttpError::ServerError))?; + + let v = Cookie::build(TOKEN_HEADER, token) + .http_only(true) + .same_site(SameSite::Strict) + .path("/") + .max_age(Duration::weeks(1)) // 1 week aligns with db set + .finish(); + + tx.commit().or(Err(MnmlHttpError::ServerError))?; + let mut res = Response::with((status::SeeOther, Redirect(Url::parse("https://mnml.gg").unwrap()))); + res.headers.set(SetCookie(vec![v.to_string()])); + + info!("recovered account account={:?}", account); + Ok(res) +} + #[derive(Debug,Clone,Deserialize)] struct SetPassword { current: String, @@ -277,7 +364,7 @@ fn set_password(req: &mut Request) -> IronResult { let state = req.get::>().unwrap(); let params = match req.get::>() { Ok(Some(b)) => b, - _ => return Err(IronError::from(MnmlHttpError::BadRequest)), + _ => return Err(MnmlHttpError::BadRequest.into()), }; match req.extensions.get::() { @@ -291,20 +378,20 @@ fn set_password(req: &mut Request) -> IronResult { Ok(token_res(token)) }, - None => Err(IronError::from(MnmlHttpError::Unauthorized)), + None => Err(MnmlHttpError::Unauthorized.into()), } } #[derive(Debug,Clone,Deserialize)] -struct EmailSet { +struct EmailPost { email: String, } fn email_set(req: &mut Request) -> IronResult { let state = req.get::>().unwrap(); - let params = match req.get::>() { + let params = match req.get::>() { Ok(Some(b)) => b, - _ => return Err(IronError::from(MnmlHttpError::BadRequest)), + _ => return Err(MnmlHttpError::BadRequest.into()), }; let db = state.pool.get().or(Err(MnmlHttpError::DbError))?; @@ -312,7 +399,7 @@ fn email_set(req: &mut Request) -> IronResult { let (email, account, token) = match req.extensions.get::() { Some(a) => { - let (_id, token) = match mail::insert(&mut tx, a.id, ¶ms.email) { + let (_id, token) = match mail::set(&mut tx, a.id, ¶ms.email) { Ok(res) => res, Err(e) => { warn!("{:?}", e); @@ -322,15 +409,18 @@ fn email_set(req: &mut Request) -> IronResult { (params.email.clone(), a.clone(), token) }, - None => return Err(IronError::from(MnmlHttpError::Unauthorized)), + None => return Err(MnmlHttpError::Unauthorized.into()), }; let app_mailer = req.get::>().unwrap(); - let send = match app_mailer.lock().unwrap().mailer.send(mail::confirm(&email, &account.name, &token)) { + let mut lock = app_mailer.lock().unwrap(); + let message = Mail::Confirm { email: email.clone(), name: account.name.clone(), token }; + + let send = match mail::send_mail(&mut lock.mailer, message) { Ok(send) => send, Err(e) => { warn!("{:?}", e); - return Err(IronError::from(MnmlHttpError::ServerError)); + return Err(MnmlHttpError::ServerError.into()); } }; @@ -345,7 +435,7 @@ fn email_confirm(req: &mut Request) -> IronResult { let account = match req.extensions.get::() { Some(a) => a.clone(), - None => return Err(IronError::from(MnmlHttpError::Unauthorized)), + None => return Err(MnmlHttpError::Unauthorized.into()), }; match req.get_ref::() { @@ -355,12 +445,12 @@ fn email_confirm(req: &mut Request) -> IronResult { let token = match hashmap.get("confirm_token") { Some(t) => &t[0], - None => return Err(IronError::from(MnmlHttpError::BadRequest)), + None => return Err(MnmlHttpError::BadRequest.into()), }; let confirmation = match mail::confirm_email(&mut tx, &account, token.to_string()) { Ok(c) => c, - Err(_) => return Err(IronError::from(MnmlHttpError::NotFound)) + Err(_) => return Err(MnmlHttpError::NotFound.into()) }; info!("email confirmed email={:?} account={:?}", confirmation.0, account); @@ -368,7 +458,7 @@ fn email_confirm(req: &mut Request) -> IronResult { tx.commit().or(Err(MnmlHttpError::ServerError))?; Ok(Response::with((status::Found, Redirect(Url::parse("https://mnml.gg").unwrap())))) }, - Err(_) => Err(IronError::from(MnmlHttpError::BadRequest)), + Err(_) => Err(MnmlHttpError::BadRequest.into()), } } @@ -393,9 +483,11 @@ fn account_mount() -> Router { router.post("register", register, "register"); router.post("password", set_password, "set_password"); router.post("email", email_set, "email_set"); + router.post("recover", recover_set, "recover_set"); // it is sent in an email... router.get("email/confirm", email_confirm, "email_confirm"); + router.get("recover", recover, "recover"); router } diff --git a/server/src/mail.rs b/server/src/mail.rs index 3a13977a..00d3950c 100644 --- a/server/src/mail.rs +++ b/server/src/mail.rs @@ -19,7 +19,17 @@ use lettre::{SendableEmail, SmtpClient, SmtpTransport, Transport}; use lettre_email::Email; use account::Account; +use pg::Db; +#[derive(Debug)] +pub struct UserEmail { + pub id: Uuid, + pub email: String, + pub account: Uuid, + pub confirmed: bool, +} + +#[derive(Debug)] pub enum Mail { Recover { email: String, name: String, token: String }, Confirm { email: String, name: String, token: String }, @@ -29,18 +39,25 @@ pub enum Mail { // put msg saying pls reset your password // redirect to main page cause cbf -fn recover(email: String, name: String, token: String) -> SendableEmail { +fn recover(email: &String, name: &String, token: &String) -> SendableEmail { + let body = format!("{:}, +the link below will recover your account. +please change your password immediately in the account page +http://mnml.gg/api/account/recover?recover_token={:} + +glhf", name, token); + Email::builder() .from("machines@mnml.gg") - .to(email) - .subject("recovery phase") - .text(format!("{:},\nyour token has been reset to\n{:}\n", name, token)) + .to(email.clone()) + .subject("account recovery") + .text(body) .build() .unwrap() .into() } -pub fn confirm(email: &String, name: &String, token: &String) -> SendableEmail { +fn confirm(email: &String, name: &String, token: &String) -> SendableEmail { let confirm_body = format!("{:}, please click the link below to confirm your email http://mnml.gg/api/account/email/confirm?confirm_token={:} @@ -57,9 +74,9 @@ glhf", name, token); .into() } -fn send_mail(mailer: &mut SmtpTransport, mail: Mail) -> Result { +pub fn send_mail(mailer: &mut SmtpTransport, mail: Mail) -> Result { let msg = match mail { - Mail::Recover { email, name, token } => recover(email, name, token), + Mail::Recover { email, name, token } => recover(&email, &name, &token), Mail::Confirm { email, name, token } => confirm(&email, &name, &token), }; @@ -88,7 +105,87 @@ pub fn confirm_email(tx: &mut Transaction, account: &Account, confirm_token: Str return Ok((email, account)); } -pub fn insert(tx: &mut Transaction, account: Uuid, email: &String) -> Result<(Uuid, String), Error> { +pub fn select(db: &Db, email: &String) -> Result { + let query = " + SELECT id, email, account, confirmed + FROM emails + WHERE email = $1; + "; + + let result = db + .query(query, &[&email])?; + + let row = result.iter().next() + .ok_or(err_msg("email found"))?; + + let id: Uuid = row.get(0); + let email: String = row.get(1); + let account: Uuid = row.get(2); + let confirmed: bool = row.get(3); + + return Ok(UserEmail { id, email, account, confirmed }); +} + +pub fn set_recovery(tx: &mut Transaction, email: &String) -> Result { + let mut rng = thread_rng(); + let recover_token: String = iter::repeat(()) + .map(|()| rng.sample(Alphanumeric)) + .take(64) + .collect(); + + let query = " + UPDATE emails + SET recover_token = $1, recover_token_expiry = now() + interval '2 days' + WHERE email = $2 + AND confirmed = true + RETURNING id, email, account + "; + + let result = tx + .query(query, &[&recover_token, &email])?; + + let row = result.iter().next() + .ok_or(format_err!("no confirmed email found {:?}", email))?; + + let _id: Uuid = row.get(0); + let _email: String = row.get(1); + let _account: Uuid = row.get(2); + + return Ok(recover_token); +} + +pub fn get_recovery(tx: &mut Transaction, recover_token: &String) -> Result { + // set a new token when recovering to prevent multiple access + let mut rng = thread_rng(); + let new_token: String = iter::repeat(()) + .map(|()| rng.sample(Alphanumeric)) + .take(64) + .collect(); + + let query = " + UPDATE emails + SET recover_token = $1, recover_token_expiry = now() + WHERE recover_token = $2 + AND recover_token_expiry > now() + AND confirmed = true + RETURNING id, email, account, confirmed; + "; + + let result = tx + .query(query, &[&new_token, &recover_token])?; + + let row = result.iter().next() + .ok_or(err_msg("no confirmed email found"))?; + + let id: Uuid = row.get(0); + let email: String = row.get(1); + let account: Uuid = row.get(2); + let confirmed: bool = row.get(3); + + return Ok(UserEmail { id, email, account, confirmed }); +} + +pub fn set(tx: &mut Transaction, account: Uuid, email: &String) -> Result<(Uuid, String), Error> { let id = Uuid::new_v4(); let mut rng = thread_rng(); @@ -97,18 +194,41 @@ pub fn insert(tx: &mut Transaction, account: Uuid, email: &String) -> Result<(Uu .take(64) .collect(); - let query = " - INSERT INTO emails (id, account, email, confirm_token) - VALUES ($1, $2, $3, $4) + let recover_token: String = iter::repeat(()) + .map(|()| rng.sample(Alphanumeric)) + .take(64) + .collect(); + + let insert_query = " + INSERT INTO emails (id, account, email, confirm_token, confirmed, recover_token) + VALUES ($1, $2, $3, $4, false, $5) RETURNING id; "; - let result = tx - .query(query, &[&id, &account, &email, &confirm_token])?; + let update_query = " + UPDATE emails + SET email = $1, confirm_token = $2, confirmed = false, recover_token = $3 + WHERE account = $4 + RETURNING id; + "; + + let result = match tx.query(insert_query, &[&id, &account, &email, &confirm_token, &recover_token]) { + Ok(r) => r, + // email update probably + Err(_) => { + match tx.query(update_query, &[&email, &confirm_token, &recover_token, &account]) { + Ok(r) => r, + Err(e) => { + warn!("{:?}", e); + return Err(err_msg("no email set")); + }, + } + } + }; match result.iter().next() { Some(row) => row, - None => return Err(err_msg("no email inserted")), + None => return Err(err_msg("no email set")), }; return Ok((id, confirm_token)); diff --git a/server/src/rpc.rs b/server/src/rpc.rs index 7bb2064e..8320c313 100644 --- a/server/src/rpc.rs +++ b/server/src/rpc.rs @@ -308,7 +308,7 @@ impl Handler for Connection { // got auth token if cookie.name() == TOKEN_HEADER { let db = self.pool.get().unwrap(); - match account::from_token(&db, cookie.value().to_string()) { + match account::from_token(&db, &cookie.value().to_string()) { Ok(a) => self.account = Some(a), Err(_) => return unauth(), }