浏览代码

Add basic user management CLI, fleshed out login to successfully authenticate.

Kestrel 2 年之前
父节点
当前提交
98ef98633c
共有 11 个文件被更改,包括 417 次插入81 次删除
  1. 36 2
      Cargo.lock
  2. 3 2
      Cargo.toml
  3. 40 0
      src/cli.rs
  4. 1 0
      src/main.rs
  5. 5 3
      src/schema.rs
  6. 6 1
      src/server.rs
  7. 243 72
      src/server/identity.rs
  8. 70 0
      src/user_management.rs
  9. 0 0
      static/logout.html
  10. 8 0
      static/style.css
  11. 5 1
      tmpl/id_v1_login.tmpl

+ 36 - 2
Cargo.lock

@@ -863,6 +863,7 @@ dependencies = [
  "serde",
  "serde_json",
  "thiserror",
+ "walkdir",
 ]
 
 [[package]]
@@ -1060,7 +1061,7 @@ checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
 
 [[package]]
 name = "microrm"
-version = "0.2.2"
+version = "0.2.4"
 dependencies = [
  "base64 0.13.0",
  "lazy_static",
@@ -1074,7 +1075,7 @@ dependencies = [
 
 [[package]]
 name = "microrm-macros"
-version = "0.2.0"
+version = "0.2.1"
 dependencies = [
  "convert_case",
  "proc-macro2",
@@ -1404,6 +1405,18 @@ version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "56770675ebc04927ded3e60633437841581c285dc6236109ea25fbf3beb7b59e"
 
+[[package]]
+name = "rpassword"
+version = "6.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bf099a1888612545b683d2661a1940089f6c2e5a8e38979b2159da876bfd956"
+dependencies = [
+ "libc",
+ "serde",
+ "serde_json",
+ "winapi",
+]
+
 [[package]]
 name = "rustc_version"
 version = "0.2.3"
@@ -1419,6 +1432,15 @@ version = "1.0.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f"
 
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
 [[package]]
 name = "semver"
 version = "0.9.0"
@@ -1966,6 +1988,7 @@ dependencies = [
  "log",
  "microrm",
  "ring",
+ "rpassword",
  "serde",
  "serde_bytes",
  "serde_json",
@@ -1980,6 +2003,17 @@ version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca"
 
+[[package]]
+name = "walkdir"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56"
+dependencies = [
+ "same-file",
+ "winapi",
+ "winapi-util",
+]
+
 [[package]]
 name = "wasi"
 version = "0.9.0+wasi-snapshot-preview1"

+ 3 - 2
Cargo.toml

@@ -16,11 +16,11 @@ sha2 = { version = "0.10.2" }
 base64 = { version = "0.13.0" }
 log = "0.4"
 stderrlog = "0.5"
-handlebars = "4.3"
+handlebars = { version = "4.3", features = ["dir_source"] }
 lazy_static = "1.4.0"
 
 # Data storage dependencies
-microrm = { path = "../microrm/microrm", version = "0.2.1" }
+microrm = { path = "../microrm/microrm", version = "0.2.4" }
 
 # Public API dependencies
 tide = { version = "0.16.0" }
@@ -28,3 +28,4 @@ anyhow = { version = "1.0" }
 
 # CLI dependencies
 clap = { version = "3.1.15", features = ["derive"] }
+rpassword = "6.0"

+ 40 - 0
src/cli.rs

@@ -18,6 +18,7 @@ enum Command {
     Init,
     Cert(CertArgs),
     Server(ServerArgs),
+    User(UserArgs)
 }
 
 impl RootArgs {
@@ -42,6 +43,7 @@ impl RootArgs {
             Command::Init => unreachable!(),
             Command::Cert(v) => v.run(&self, &storage).await,
             Command::Server(v) => v.run(&self, storage).await,
+            Command::User(v) => v.run(&self, storage).await,
         }
     }
 
@@ -113,3 +115,41 @@ pub fn invoked() {
 
     async_std::task::block_on(args.run());
 }
+
+#[derive(Debug, Parser)]
+struct CreateArgs {
+    username: String
+}
+
+#[derive(Debug, Parser)]
+struct AuthArgs {
+    username: String,
+
+    #[clap(short='p', long, parse(from_occurrences))]
+    change_password: usize,
+}
+
+#[derive(Debug, Subcommand)]
+enum UserCommand {
+    List,
+    Create(CreateArgs),
+    Auth(AuthArgs),
+}
+
+#[derive(Debug, Parser)]
+struct UserArgs {
+    #[clap(subcommand)]
+    command: UserCommand
+}
+
+
+impl UserArgs {
+    async fn run(&self, root: &RootArgs, db: microrm::DB) {
+        match &self.command {
+            UserCommand::List => { crate::user_management::list(&root.realm, db) }
+            UserCommand::Create(args) => { crate::user_management::create(&root.realm, db, args.username.as_str()) }
+            UserCommand::Auth(args) => { crate::user_management::change_auth(&root.realm, db, args.username.as_str(), args.change_password > 0) }
+        }
+        // crate::user_management::
+    }
+}

+ 1 - 0
src/main.rs

@@ -3,6 +3,7 @@ mod cli;
 mod login;
 mod schema;
 mod server;
+mod user_management;
 
 fn main() {
     stderrlog::new()

+ 5 - 3
src/schema.rs

@@ -43,7 +43,7 @@ pub struct User {
     pub username: String,
 }
 
-#[derive(Clone, Copy, Debug, Modelable, Serialize, Deserialize)]
+#[derive(Clone, Copy, Debug, PartialEq, Modelable, Serialize, Deserialize)]
 pub enum AuthChallengeType {
     Username,
     Password,
@@ -57,8 +57,10 @@ pub struct AuthChallenge {
     #[microrm_foreign]
     pub user: UserID,
     pub challenge_type: AuthChallengeType,
-    pub public: String,
-    pub secret: String,
+    #[serde(with = "serde_bytes")]
+    pub public: Vec<u8>,
+    #[serde(with = "serde_bytes")]
+    pub secret: Vec<u8>,
 }
 
 #[derive(Entity, Serialize, Deserialize)]

+ 6 - 1
src/server.rs

@@ -33,6 +33,11 @@ pub async fn run_server(db: microrm::DB) {
     let core_state = Box::leak(Box::new(ServerCoreState { pool, templates: std::sync::RwLock::new(handlebars::Handlebars::new()) }));
     let state = ServerState { core: core_state };
 
+    // XXX: for development only
+    core_state.templates.write().unwrap().set_dev_mode(true);
+
+    core_state.templates.write().unwrap().register_templates_directory("tmpl", "tmpl").expect("Couldn't open templates directory?");
+
     let mut app = tide::with_state(state);
 
     app.with(tide::log::LogMiddleware::new());
@@ -44,7 +49,7 @@ pub async fn run_server(db: microrm::DB) {
         .expect("Can't serve logout.html");*/
 
     app.at("/static")
-        .serve_dir("srv/")
+        .serve_dir("static/")
         .expect("Can't serve static files");
 
     app.at("/:realm/v1/id/")

+ 243 - 72
src/server/identity.rs

@@ -1,13 +1,90 @@
 use crate::schema;
+use serde::Deserialize;
 use tide::http::Cookie;
 
 #[derive(Clone)]
 pub struct ServerState {
     core: &'static super::ServerCoreState,
+    realm_cache: std::sync::Arc<std::sync::RwLock<std::collections::HashMap<String, schema::RealmID>>>
 }
 
 type Request = tide::Request<ServerState>;
 
+impl ServerState {
+    pub fn get_realm(&self, req: &Request) -> Option<schema::RealmID> {
+        let realm_str = req.param("realm").expect("get_realm called with no :realm param");
+        let cache = self.realm_cache.read().unwrap();
+        let cache_lookup = cache.get(realm_str);
+
+        // expected case
+        if cache_lookup.is_some() { return cache_lookup.map(|x| *x) }
+        drop(cache);
+
+        // unexpected case, but maybe we haven't filled that cache entry yet
+        
+        let qi = self.core.pool.query_interface();
+        let realm = qi.get_one_by(schema::RealmColumns::Shortname, realm_str);
+
+        if let Some(with_id) = realm {
+            let mut cache = self.realm_cache.write().unwrap();
+            cache.insert(realm_str.to_owned(), with_id.id());
+            return Some(with_id.id())
+        }
+
+        // other expected case, is bogus realm
+        return None
+    }
+
+    fn build_session(
+        qi: &microrm::QueryInterface
+    ) -> tide::Result<(schema::SessionID, Option<tide::http::Cookie<'static>>)> {
+        let rng = ring::rand::SystemRandom::new();
+        let session_id: [u8; 32] = ring::rand::generate(&rng)
+            .map_err(|_| tide::Error::from_str(500, "Failed to generate session ID"))?
+            .expose();
+        let session_id = base64::encode_config(session_id, base64::URL_SAFE_NO_PAD);
+
+        // response.insert_cookie(Cookie::new("vogt_session", session_id.clone()));
+
+        let maybe_id = qi.add(&crate::schema::Session { key: session_id.clone() });
+        Ok((maybe_id.ok_or(tide::Error::from_str(
+            500,
+            "Failed to store session in database",
+        ))?, Some(Cookie::new("vogt_session", session_id))))
+    }
+
+    pub fn get_or_build_session(&self, req: &Request) -> tide::Result<(schema::SessionID, Option<tide::http::Cookie<'static>>)> {
+        println!("get_or_build_session()...");
+        let qi = self.core.pool.query_interface();
+        if let Some(sid) = req.cookie("vogt_session") {
+            println!("cookie is set ...");
+            let existing = qi.get_one_by(schema::SessionColumns::Key, sid.value());
+
+            if existing.is_some() {
+                println!("recognize the session!");
+                return Ok((existing.unwrap().id(), None))
+            }
+            println!("don't recognize the session {}", sid.value());
+        }
+        println!("building new session!");
+        Self::build_session(qi)
+    }
+
+    pub fn get_auth_for_session(&self, realm: schema::RealmID, session: schema::SessionID) -> Option<microrm::WithID<schema::SessionAuthentication>> {
+        let qi = self.core.pool.query_interface();
+
+        use schema::SessionAuthenticationColumns as SAC;
+        qi.get_one_by_multi(&[SAC::Realm, SAC::Session], &microrm::value_list!(&realm, &session))
+    }
+
+    pub fn destroy_auth(&self, realm: schema::RealmID, session: schema::SessionID) {
+        let qi = self.core.pool.query_interface();
+        
+        use schema::SessionAuthenticationColumns as SAC;
+        qi.delete_by_multi(&[SAC::Realm, SAC::Session], &microrm::value_list!(&realm, &session));
+    }
+}
+
 async fn v1_check(req: Request) -> tide::Result<tide::Response> {
     let validity = super::is_auth_valid(req.state().core, &req);
     if let Some(true) = validity {
@@ -19,62 +96,13 @@ async fn v1_check(req: Request) -> tide::Result<tide::Response> {
     }
 }
 
-fn build_session(
-    qi: &microrm::QueryInterface,
-    response: &mut tide::Response,
-) -> tide::Result<schema::SessionID> {
-    let rng = ring::rand::SystemRandom::new();
-    let session_id: [u8; 32] = ring::rand::generate(&rng)
-        .map_err(|_| tide::Error::from_str(500, "Failed to generate session ID"))?
-        .expose();
-    let session_id = base64::encode_config(session_id, base64::URL_SAFE_NO_PAD);
-
-    response.insert_cookie(Cookie::new("vogt_session", session_id.clone()));
-
-    let maybe_id = qi.add(&crate::schema::Session { key: session_id });
-    Ok(maybe_id.ok_or(tide::Error::from_str(
-        500,
-        "Failed to store session in database",
-    ))?)
-}
-
-fn render_login_page(state: &ServerState, mut response: tide::Response, to_present: schema::AuthChallengeType) -> tide::Response {
-    let tmpl = state.core.templates.read().unwrap();
-
-    let do_challenge = |ty,ch| {
-        tmpl.render("id_v1_login", &serde_json::json!(
-            {
-                "challenge":
-                    format!(r#"
-                        <input type="hidden" name="challenge-type" value="{:?} {}" />
-                        <div class="challenge-type">{}</div>
-                        <div class="challenge-content">{}</div>
-                        "#,
-                        to_present, serde_json::to_string(&to_present).unwrap(),ty,ch)
-            }
-        )).unwrap()
-    };
 
-    response.set_content_type("text/html");
-
-    match to_present {
-        schema::AuthChallengeType::Username => {
-            response.set_body(do_challenge("Username",
-                r#"<input name="challenge" type="text" autofocus />"#));
-        },
-        schema::AuthChallengeType::Password => {
-            response.set_body(do_challenge("Password",
-                r#"<input name="challenge" type="password" autofocus />"#));
-        }
-        _ => todo!()
-    }
-
-    response
-}
-
-async fn v1_login(mut req: tide::Request<ServerState>) -> tide::Result<tide::Response> {
-    let mut response = tide::Response::builder(200).build();
+/*
+fn get_session_auth(req: &tide::Request<ServerState>, response: &mut tide::Response) -> Option<microrm::WithID<schema::SessionAuthentication>> {
     let qi = req.state().core.pool.query_interface();
+    let realm_name = req
+        .param("realm")
+        .expect("Failed to parse realm out of path?");
 
     let sid: schema::SessionID = match req.cookie("vogt_session") {
         Some(sid) => {
@@ -83,19 +111,13 @@ async fn v1_login(mut req: tide::Request<ServerState>) -> tide::Result<tide::Res
             if let Some(id) = existing {
                 id.id()
             } else {
-                build_session(&qi, &mut response)?
+                build_session(&qi, response).ok()?
             }
         }
-        None => build_session(&qi, &mut response)?,
+        None => build_session(&qi, response).ok()?,
     };
 
-    let realm_name = req
-        .param("realm")
-        .expect("Failed to parse realm out of path?");
-
-    let realm_id = qi
-        .get_one_by(schema::RealmColumns::Shortname, realm_name)
-        .ok_or(tide::Error::from_str(404, "No such realm"))?;
+    let realm_id = qi.get_one_by(schema::RealmColumns::Shortname, realm_name)?;
 
     let realm_auth = qi.get_one_by_multi(
         &[
@@ -105,36 +127,185 @@ async fn v1_login(mut req: tide::Request<ServerState>) -> tide::Result<tide::Res
         &microrm::value_list!(&sid, &realm_id.id()),
     );
 
+    realm_auth
+}
+*/
+
+impl ServerState {
+    fn render_login_from_auth(&self, response: tide::Response, auth: Option<schema::SessionAuthentication>, error_msg: Option<String>) -> tide::Response {
+        let to_present: Option<schema::AuthChallengeType> = match auth {
+            None => Some(schema::AuthChallengeType::Username),
+            Some(auth) => auth.challenges_left.first().copied()
+        };
+
+        if to_present.is_none() {
+            todo!("Already logged in!");
+        }
+        else {
+            self.render_login_page(response, to_present.unwrap(), error_msg)
+        }
+    }
+
+    fn render_login_page(&self, mut response: tide::Response, to_present: schema::AuthChallengeType, error_msg: Option<String>) -> tide::Response {
+        let tmpl = self.core.templates.read().unwrap();
+
+        let do_challenge = |ty,ch| {
+            tmpl.render("id_v1_login", &serde_json::json!(
+                {
+                    "challenge":
+                        format!(r#"
+                            <input type="hidden" name="challenge_type" value="{:?}" />
+                            <div class="challenge-type">{}</div>
+                            <div class="challenge-content">{}</div>
+                            "#,
+                            to_present, ty, ch),
+                    "error_msg": error_msg.iter().collect::<Vec<_>>()
+                }
+            )).unwrap()
+        };
+
+        response.set_content_type("text/html");
+
+        match to_present {
+            schema::AuthChallengeType::Username => {
+                response.set_body(do_challenge("Username",
+                    r#"<input name="challenge" type="text" autofocus />"#));
+            },
+            schema::AuthChallengeType::Password => {
+                response.set_body(do_challenge("Password",
+                    r#"<input name="challenge" type="password" autofocus />"#));
+            },
+            _ => todo!()
+        }
+
+        response
+    }
+}
+
+async fn v1_login(req: tide::Request<ServerState>) -> tide::Result<tide::Response> {
+    let mut response = tide::Response::builder(200).build();
+
+    // let qi = req.state().core.pool.query_interface();
+
+    let realm = req.state().get_realm(&req).ok_or(tide::Error::from_str(404, "No such realm"))?;
+    let (session_id, cookie) = req.state().get_or_build_session(&req)?;
+    cookie.map(|c| response.insert_cookie(c));
+
+    let auth = req.state().get_auth_for_session(realm, session_id);
+
+    Ok(req.state().render_login_from_auth(response, auth.map(|a| a.wrapped()), None))
+
+    /*let realm_auth = get_session_auth(&req, &mut response);
+
     let to_present: Option<schema::AuthChallengeType> = match realm_auth {
         None => Some(schema::AuthChallengeType::Username),
         Some(auth) => auth.challenges_left.first().map(|x| *x),
     };
 
     if to_present.is_some() {
-        Ok(render_login_page(req.state(), response, to_present.unwrap()))
+        Ok(render_login_page(req.state(), response, to_present.unwrap(), None))
     }
     else {
         // already logged in...
         todo!()
-    }
+    }*/
 }
 
 async fn v1_login_response(mut req: tide::Request<ServerState>) -> tide::Result<tide::Response> {
-    todo!()
+    let mut response = tide::Response::builder(200).build();
+
+    let realm = req.state().get_realm(&req).ok_or(tide::Error::from_str(404, "No such realm"))?;
+    let (session_id, cookie) = req.state().get_or_build_session(&req)?;
+    cookie.map(|c| response.insert_cookie(c));
+
+    let mut auth = req.state().get_auth_for_session(realm, session_id);
+
+    #[derive(Deserialize)]
+    struct ResponseBody {
+        challenge_type: String,
+        challenge: String
+    }
+
+    let body : ResponseBody = req.body_form().await?;
+
+    use schema::AuthChallengeType as ChallengeType;
+
+    let challenge: schema::AuthChallengeType = match body.challenge_type.as_str() {
+        "Username" => ChallengeType::Username,
+        "Password" => ChallengeType::Password,
+        _ => Err(tide::Error::from_str(400, "Unknown challenge type"))?
+    };
+
+    let mut error = None;
+
+    // check that the response matches what we're expecting next
+    let to_be_presented: Option<schema::AuthChallengeType> = match &auth {
+        None => Some(schema::AuthChallengeType::Username),
+        Some(auth) => auth.challenges_left.first().copied()
+    };
+
+    if to_be_presented != Some(challenge) {
+        Err(tide::Error::from_str(400, "Incorrect challenge type"))?
+    }
+
+    match challenge {
+        ChallengeType::Username => {
+            let qi = req.state().core.pool.query_interface();
+            req.state().destroy_auth(realm, session_id);
+
+            let user = qi.get_one_by_multi(&[schema::UserColumns::Realm, schema::UserColumns::Username], &microrm::value_list![&realm, &body.challenge]);
+            if user.is_none() {
+                error = Some(format!("No such user {}", body.challenge));
+            }
+            else {
+                let user = user.unwrap();
+
+                let sa = schema::SessionAuthentication { session: session_id, realm: realm, user: user.id(), challenges_left: vec![schema::AuthChallengeType::Password] };
+                let id = qi.add(&sa).unwrap();
+                auth = Some(microrm::WithID::new(sa, id));
+            }
+            // req.state().destroy_auth(realm, session_id);
+
+        },
+        ChallengeType::Password => {
+            if auth.is_none() {
+                error = Some(format!("Please restart login process."));
+            }
+            else {
+                let qi = req.state().core.pool.query_interface();
+
+                use schema::AuthChallengeColumns;
+                let challenge = qi.get_one_by_multi(&[AuthChallengeColumns::User, AuthChallengeColumns::ChallengeType], &microrm::value_list![&auth.as_ref().unwrap().user, &schema::AuthChallengeType::Password]);
+
+                if challenge.is_none() {
+                    error = Some(format!("User lacks a password. Please contact an administrator."));
+                }
+                else {
+                    use ring::pbkdf2;
+
+                    let challenge = challenge.unwrap();
+
+                    let verification = pbkdf2::verify(pbkdf2::PBKDF2_HMAC_SHA256, std::num::NonZeroU32::new(20000).unwrap(), challenge.public.as_slice(), body.challenge.as_bytes(), challenge.secret.as_slice());
+
+                    if verification.is_ok() {
+                        auth.as_mut().unwrap().challenges_left.remove(0);
+                    }
+                }
+            }
+        },
+        _ => todo!()
+    };
+
+    Ok(req.state().render_login_from_auth(response, auth.map(|a| a.wrapped()), error))
 }
 
 pub fn id_v1_server(core: &'static super::ServerCoreState) -> tide::Server<ServerState> {
-    let mut srv = tide::with_state(ServerState { core });
+    let mut srv = tide::with_state(ServerState { core, realm_cache: std::sync::Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())) });
 
     srv.with(tide::log::LogMiddleware::new());
 
     srv.at("login").get(v1_login).post(v1_login_response);
     srv.at("check").get(v1_check).head(v1_check);
 
-    // load and register templates
-    core.templates.write().unwrap()
-        .register_template_file("id_v1_login", "srv/login.html")
-        .expect("Couldn't register template!");
-
     srv
 }

+ 70 - 0
src/user_management.rs

@@ -0,0 +1,70 @@
+use crate::schema;
+
+pub fn list(realm: &str, db: microrm::DB) {
+    // get realm ID
+    let qi = db.query_interface();
+
+    let realm_id = qi.get_one_by(schema::RealmColumns::Shortname, realm).expect("No such realm").id();
+
+    let users = qi.get_all_by(schema::UserColumns::Realm, realm_id).expect("Can't get list of users");
+
+    println!("User list ({} users):", users.len());
+
+    for user in &users {
+        println!("- {:20}", user.username);
+        let auth_challenges = qi.get_all_by(schema::AuthChallengeColumns::User, user.id()).expect("Can't get authentication challenges?");
+        for ch in &auth_challenges {
+            println!("    - Has {:?} authentication challenge", ch.challenge_type);
+        }
+    }
+}
+
+pub fn create(realm: &str, db: microrm::DB, username: &str) {
+    // get realm ID
+    let qi = db.query_interface();
+
+    let realm_id = qi.get_one_by(schema::RealmColumns::Shortname, realm).expect("No such realm").id();
+
+    // check that the user doesn't exist already
+    let existing_user = qi.get_one_by_multi(&[schema::UserColumns::Realm, schema::UserColumns::Username], &microrm::value_list![&realm_id, &username]);
+
+    if existing_user.is_some() {
+        log::error!("Can't create user {} in {} realm as a user with that username already exists", username, realm);
+        return;
+    }
+
+    qi.add(&schema::User { realm: realm_id, username: username.to_owned() });
+}
+
+pub fn change_auth(realm: &str, db: microrm::DB, username: &str, change_password: bool) {
+    // get realm ID
+    let qi = db.query_interface();
+
+    let realm_id = qi.get_one_by(schema::RealmColumns::Shortname, realm).expect("No such realm").id();
+    // check that the user exists
+    let existing_user = qi.get_one_by_multi(&[schema::UserColumns::Realm, schema::UserColumns::Username], &microrm::value_list![&realm_id, &username]);
+    if existing_user.is_none() {
+        log::error!("User {} does not exist in the {} realm!", username, realm);
+        return;
+    }
+
+    let user = existing_user.unwrap();
+
+    if change_password {
+        qi.delete_by_multi(&[schema::AuthChallengeColumns::User, schema::AuthChallengeColumns::ChallengeType], &microrm::value_list![&user.id(), &schema::AuthChallengeType::Password]).expect("Couldn't delete existing password?");
+
+        let raw_pass = rpassword::prompt_password("Enter new user password: ").unwrap();
+
+        // store hashed password
+        use ring::pbkdf2;
+
+        let rng = ring::rand::SystemRandom::new();
+        let salt: [u8; 16] = ring::rand::generate(&rng).expect("Couldn't generate random salt?").expose();
+
+        let mut generated = [0u8; ring::digest::SHA256_OUTPUT_LEN];
+
+        pbkdf2::derive(pbkdf2::PBKDF2_HMAC_SHA256, std::num::NonZeroU32::new(20000).unwrap(), &salt, raw_pass.as_bytes(), &mut generated);
+
+        qi.add(&schema::AuthChallenge { user: user.id(), challenge_type: schema::AuthChallengeType::Password, public: salt.into(), secret: generated.into() });
+    }
+}

+ 0 - 0
srv/logout.html → static/logout.html


+ 8 - 0
srv/style.css → static/style.css

@@ -48,6 +48,14 @@ div.login-challenge div.challenge-content {
     display: inline-block;
 }
 
+div.error-msg {
+    background: #fcc;
+    border: #888 1px solid;
+    border-radius: 5pt;
+    padding-left: 1em;
+    padding-right: 1em;
+}
+
 div.footer {
     text-align: center;
     font-size: .75em;

+ 5 - 1
srv/login.html → tmpl/id_v1_login.tmpl

@@ -12,7 +12,11 @@
                 
                 <form action="login" method="POST">
                     <div class="login-content">
-                        <div class="spacer">&nbsp;</div>
+                        <div class="spacer">&nbsp;
+                            {{ #each error_msg }}
+                                <div class="error-msg">{{ this }}</div>
+                            {{ /each }}
+                        </div>
                         <div class="login-challenge">
                             {{{ challenge }}}
                         </div>