user authentication through a secret token (NOT AUDITED FOR SECURITY. DO NOT RELY ON THIS CODE.)

it's probably okay, but it's incredibly easy to read localStorage from the frontend and get a hold of the secret
would be nice (would it?) to have more proper session tokens I guess but we're not doing that right now

I'm not entirely sure if generating the password on the server is legit like this, but it leads to an incredibly frictionless experience and I'd like to keep it. if possible.
I don't really see a difference compared to password managers generating passwords for you and showing them in plaintext
obviously actual passwords are stored within the manager which requires a master password, but like. do we really need that. the secret isn't shown to the user and it's very long.
too bad the browser secure storage API or whatever isn't ready yet
This commit is contained in:
liquidex 2024-08-23 19:45:07 +02:00
parent 6ce1389d12
commit be6a47ae13
10 changed files with 180 additions and 32 deletions

46
Cargo.lock generated
View file

@ -53,6 +53,18 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]] [[package]]
name = "arrayref" name = "arrayref"
version = "0.3.8" version = "0.3.8"
@ -180,12 +192,27 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.6.0" version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@ -389,6 +416,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"crypto-common", "crypto-common",
"subtle",
] ]
[[package]] [[package]]
@ -958,6 +986,17 @@ dependencies = [
"windows-targets", "windows-targets",
] ]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"subtle",
]
[[package]] [[package]]
name = "paste" name = "paste"
version = "1.0.15" version = "1.0.15"
@ -1142,6 +1181,7 @@ checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
name = "rkgk" name = "rkgk"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"argon2",
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
@ -1347,6 +1387,12 @@ 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 = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.72" version = "2.0.72"

View file

@ -13,3 +13,7 @@ haku.workspace = true
log.workspace = true log.workspace = true
paste = "1.0.15" paste = "1.0.15"
[features]
default = []
std = []

View file

@ -1,4 +1,4 @@
#![no_std] #![cfg_attr(not(feature = "std"), no_std)]
extern crate alloc; extern crate alloc;
@ -20,6 +20,7 @@ use haku::{
use log::{debug, info}; use log::{debug, info};
pub mod logging; pub mod logging;
#[cfg(not(feature = "std"))]
mod panicking; mod panicking;
#[global_allocator] #[global_allocator]

View file

@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
argon2 = "0.5.3"
axum = { version = "0.7.5", features = ["macros", "ws"] } axum = { version = "0.7.5", features = ["macros", "ws"] }
base64 = "0.22.1" base64 = "0.22.1"
chrono = "0.4.38" chrono = "0.4.38"

View file

@ -34,7 +34,7 @@ struct NewUserParams {
#[serde(tag = "status", rename_all = "camelCase")] #[serde(tag = "status", rename_all = "camelCase")]
enum NewUserResponse { enum NewUserResponse {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
Ok { user_id: String }, Ok { user_id: String, secret: String },
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
Error { message: String }, Error { message: String },
@ -51,10 +51,11 @@ async fn login_new(api: State<Arc<Api>>, params: Json<NewUserParams>) -> impl In
} }
match api.dbs.login.new_user(params.0.nickname).await { match api.dbs.login.new_user(params.0.nickname).await {
Ok(user_id) => ( Ok(new_user) => (
StatusCode::OK, StatusCode::OK,
Json(NewUserResponse::Ok { Json(NewUserResponse::Ok {
user_id: user_id.to_string(), user_id: new_user.user_id.to_string(),
secret: new_user.secret,
}), }),
), ),
Err(error) => ( Err(error) => (

View file

@ -10,6 +10,7 @@ use axum::{
}, },
response::Response, response::Response,
}; };
use base64::Engine;
use eyre::{bail, Context, OptionExt}; use eyre::{bail, Context, OptionExt};
use haku::value::Value; use haku::value::Value;
use schema::{ use schema::{
@ -25,7 +26,7 @@ use tracing::{error, info, instrument};
use crate::{ use crate::{
haku::{Haku, Limits}, haku::{Haku, Limits},
login::database::LoginStatus, login::{self, database::LoginStatus},
schema::Vec2, schema::Vec2,
wall::{ wall::{
self, auto_save::AutoSave, chunk_images::ChunkImages, chunk_iterator::ChunkIterator, self, auto_save::AutoSave, chunk_images::ChunkImages, chunk_iterator::ChunkIterator,
@ -90,16 +91,22 @@ async fn fallible_websocket(api: Arc<Api>, ws: &mut WebSocket) -> eyre::Result<(
let login_request: LoginRequest = from_message(&recv_expect(ws).await?)?; let login_request: LoginRequest = from_message(&recv_expect(ws).await?)?;
let user_id = login_request.user; let user_id = login_request.user;
let secret = base64::engine::general_purpose::URL_SAFE
.decode(&login_request.secret)
.expect("invalid secret string");
if secret.len() > login::Database::MAX_SECRET_LEN {
bail!("secret is too long");
}
match api match api
.dbs .dbs
.login .login
.log_in(user_id) .log_in(user_id, secret)
.await .await
.context("error while logging in")? .context("error while logging in")?
{ {
LoginStatus::ValidUser => (), LoginStatus::ValidUser => (),
LoginStatus::UserDoesNotExist => { LoginStatus::InvalidUser => {
ws.send(to_message(&LoginResponse::UserDoesNotExist)) ws.send(to_message(&LoginResponse::UserDoesNotExist))
.await?; .await?;
return Ok(()); return Ok(());

View file

@ -22,6 +22,7 @@ pub struct Error {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct LoginRequest { pub struct LoginRequest {
pub user: UserId, pub user: UserId,
pub secret: String,
/// If null, a new wall is created. /// If null, a new wall is created.
pub wall: Option<WallId>, pub wall: Option<WallId>,
pub init: UserInit, pub init: UserInit,

View file

@ -1,8 +1,13 @@
use std::path::PathBuf; use std::path::PathBuf;
use argon2::{
password_hash::{PasswordHasher, Salt, SaltString},
Argon2, PasswordHash, PasswordVerifier,
};
use base64::Engine;
use chrono::Utc; use chrono::Utc;
use eyre::{eyre, Context}; use eyre::{eyre, Context};
use rand::SeedableRng; use rand::{RngCore, SeedableRng};
use rusqlite::{Connection, OptionalExtension}; use rusqlite::{Connection, OptionalExtension};
use tokio::sync::{mpsc, oneshot}; use tokio::sync::{mpsc, oneshot};
use tracing::instrument; use tracing::instrument;
@ -21,7 +26,7 @@ pub struct Database {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoginStatus { pub enum LoginStatus {
ValidUser, ValidUser,
UserDoesNotExist, InvalidUser,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -29,14 +34,23 @@ pub struct UserInfo {
pub nickname: String, pub nickname: String,
} }
#[derive(Debug, Clone)]
pub struct NewUser {
pub user_id: UserId,
// NOTE: This is kind of unusual, but rkgk generates a secret server-side and sends it to the
// user, rather than having the user come up with a password.
pub secret: String,
}
enum Command { enum Command {
NewUser { NewUser {
nickname: String, nickname: String,
reply: oneshot::Sender<eyre::Result<UserId>>, reply: oneshot::Sender<eyre::Result<NewUser>>,
}, },
LogIn { LogIn {
user_id: UserId, user_id: UserId,
secret: Vec<u8>,
reply: oneshot::Sender<LoginStatus>, reply: oneshot::Sender<LoginStatus>,
}, },
@ -47,7 +61,11 @@ enum Command {
} }
impl Database { impl Database {
pub async fn new_user(&self, nickname: String) -> eyre::Result<UserId> { pub const MIN_SECRET_LEN: usize = 256;
pub const MAX_SECRET_LEN: usize = 256;
pub const CURRENT_SECRET_LEN: usize = 256;
pub async fn new_user(&self, nickname: String) -> eyre::Result<NewUser> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
self.command_tx self.command_tx
.send(Command::NewUser { .send(Command::NewUser {
@ -59,10 +77,14 @@ impl Database {
rx.await.map_err(|_| eyre!("database is not available"))? rx.await.map_err(|_| eyre!("database is not available"))?
} }
pub async fn log_in(&self, user_id: UserId) -> eyre::Result<LoginStatus> { pub async fn log_in(&self, user_id: UserId, secret: Vec<u8>) -> eyre::Result<LoginStatus> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
self.command_tx self.command_tx
.send(Command::LogIn { user_id, reply: tx }) .send(Command::LogIn {
user_id,
secret,
reply: tx,
})
.await .await
.map_err(|_| eyre!("database is too contended"))?; .map_err(|_| eyre!("database is too contended"))?;
rx.await.map_err(|_| eyre!("database is not available")) rx.await.map_err(|_| eyre!("database is not available"))
@ -90,6 +112,7 @@ pub fn start(settings: &Settings) -> eyre::Result<Database> {
t_users ( t_users (
user_index INTEGER PRIMARY KEY, user_index INTEGER PRIMARY KEY,
long_user_id BLOB NOT NULL, long_user_id BLOB NOT NULL,
secret_hash BLOB NOT NULL,
nickname TEXT NOT NULL, nickname TEXT NOT NULL,
last_login_timestamp INTEGER NOT NULL last_login_timestamp INTEGER NOT NULL
); );
@ -100,6 +123,9 @@ pub fn start(settings: &Settings) -> eyre::Result<Database> {
let mut user_id_rng = rand_chacha::ChaCha20Rng::from_entropy(); let mut user_id_rng = rand_chacha::ChaCha20Rng::from_entropy();
let mut secret_rng = rand_chacha::ChaCha20Rng::from_entropy();
let argon2 = Argon2::default();
std::thread::Builder::new() std::thread::Builder::new()
.name("login database thread".into()) .name("login database thread".into())
.spawn(move || { .spawn(move || {
@ -107,8 +133,18 @@ pub fn start(settings: &Settings) -> eyre::Result<Database> {
.prepare( .prepare(
r#" r#"
INSERT INTO t_users INSERT INTO t_users
(long_user_id, nickname, last_login_timestamp) (long_user_id, nickname, secret_hash, last_login_timestamp)
VALUES (?, ?, ?); VALUES (?, ?, ?, ?);
"#,
)
.unwrap();
let mut s_get_secret = db
.prepare(
r#"
SELECT secret_hash
FROM t_users
WHERE long_user_id = ?;
"#, "#,
) )
.unwrap(); .unwrap();
@ -137,21 +173,61 @@ pub fn start(settings: &Settings) -> eyre::Result<Database> {
while let Some(command) = command_rx.blocking_recv() { while let Some(command) = command_rx.blocking_recv() {
match command { match command {
Command::NewUser { nickname, reply } => { Command::NewUser { nickname, reply } => {
let user_id = UserId::new(&mut user_id_rng); let result = || -> eyre::Result<_> {
let result = s_insert_user let user_id = UserId::new(&mut user_id_rng);
.execute((user_id.0, nickname, Utc::now().timestamp()))
.context("could not execute query"); let mut secret = [0; Database::CURRENT_SECRET_LEN];
_ = reply.send(result.map(|_| user_id)); secret_rng.fill_bytes(&mut secret);
let salt = SaltString::generate(&mut secret_rng);
let password_hash = argon2
.hash_password(&secret, &salt)
.expect("bad argon2 parameters");
s_insert_user
.execute((
user_id.0,
nickname,
password_hash.to_string(),
Utc::now().timestamp(),
))
.context("could not execute query")?;
Ok(NewUser {
user_id,
secret: base64::engine::general_purpose::URL_SAFE.encode(secret),
})
}();
_ = reply.send(result);
} }
Command::LogIn { user_id, reply } => { Command::LogIn {
user_id,
secret,
reply,
} => {
// TODO: User expiration. // TODO: User expiration.
let login_status = let result = || -> eyre::Result<_> {
match s_log_in.execute((Utc::now().timestamp(), user_id.0)) { let secret_hash: String = s_get_secret
Ok(_) => LoginStatus::ValidUser, .query_row((user_id.0,), |row| row.get(0))
Err(_) => LoginStatus::UserDoesNotExist, .context("no such user")?;
};
_ = reply.send(login_status); let hash = PasswordHash::new(&secret_hash)
.map_err(|_| eyre!("invalid secret hash"))?;
argon2
.verify_password(&secret, &hash)
.map_err(|_| eyre!("invalid secret"))?;
s_log_in
.execute((Utc::now().timestamp(), user_id.0))
.context("no such user")?;
Ok(())
}();
_ = reply.send(match result {
Ok(_) => LoginStatus::ValidUser,
Err(_) => LoginStatus::InvalidUser,
});
} }
Command::UserInfo { user_id, reply } => { Command::UserInfo { user_id, reply } => {

View file

@ -1,5 +1,5 @@
import { Wall } from "./wall.js"; import { Wall } from "./wall.js";
import { getUserId, newSession, waitForLogin } from "./session.js"; import { getLoginSecret, getUserId, newSession, waitForLogin } from "./session.js";
import { debounce } from "./framework.js"; import { debounce } from "./framework.js";
import { ReticleCursor } from "./reticle-renderer.js"; import { ReticleCursor } from "./reticle-renderer.js";
@ -59,6 +59,7 @@ function readUrl() {
let session = await newSession( let session = await newSession(
getUserId(), getUserId(),
getLoginSecret(),
urlData.wallId ?? localStorage.getItem("rkgk.mostRecentWallId"), urlData.wallId ?? localStorage.getItem("rkgk.mostRecentWallId"),
{ {
brush: brushEditor.code, brush: brushEditor.code,

View file

@ -17,6 +17,10 @@ export function getUserId() {
return loginStorage.userId; return loginStorage.userId;
} }
export function getLoginSecret() {
return loginStorage.secret;
}
export function waitForLogin() { export function waitForLogin() {
return loggedInPromise; return loggedInPromise;
} }
@ -54,8 +58,8 @@ export async function registerUser(nickname) {
}; };
} }
console.log(responseJson);
loginStorage.userId = responseJson.userId; loginStorage.userId = responseJson.userId;
loginStorage.secret = responseJson.secret;
console.info("user registered", loginStorage.userId); console.info("user registered", loginStorage.userId);
saveLoginStorage(); saveLoginStorage();
resolveLoggedInPromise(); resolveLoggedInPromise();
@ -71,9 +75,10 @@ export async function registerUser(nickname) {
} }
class Session extends EventTarget { class Session extends EventTarget {
constructor(userId) { constructor(userId, secret) {
super(); super();
this.userId = userId; this.userId = userId;
this.secret = secret;
} }
async #recvJson() { async #recvJson() {
@ -138,6 +143,9 @@ class Session extends EventTarget {
} }
async joinInner(wallId, userInit) { async joinInner(wallId, userInit) {
let secret = this.secret;
this.secret = null;
let version = await this.#recvJson(); let version = await this.#recvJson();
console.info("protocol version", version.version); console.info("protocol version", version.version);
// TODO: This should probably verify that the version is compatible. // TODO: This should probably verify that the version is compatible.
@ -149,11 +157,13 @@ class Session extends EventTarget {
if (this.wallId == null) { if (this.wallId == null) {
this.#sendJson({ this.#sendJson({
user: this.userId, user: this.userId,
secret,
init, init,
}); });
} else { } else {
this.#sendJson({ this.#sendJson({
user: this.userId, user: this.userId,
secret,
wall: wallId, wall: wallId,
init, init,
}); });
@ -259,8 +269,8 @@ class Session extends EventTarget {
} }
} }
export async function newSession(userId, wallId, userInit) { export async function newSession(userId, secret, wallId, userInit) {
let session = new Session(userId); let session = new Session(userId, secret);
await session.join(wallId, userInit); await session.join(wallId, userInit);
return session; return session;
} }