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:
parent
6ce1389d12
commit
be6a47ae13
46
Cargo.lock
generated
46
Cargo.lock
generated
|
@ -53,6 +53,18 @@ dependencies = [
|
|||
"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]]
|
||||
name = "arrayref"
|
||||
version = "0.3.8"
|
||||
|
@ -180,12 +192,27 @@ version = "0.22.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
|
@ -389,6 +416,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -958,6 +986,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
|
@ -1142,6 +1181,7 @@ checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
|
|||
name = "rkgk"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
@ -1347,6 +1387,12 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.72"
|
||||
|
|
|
@ -13,3 +13,7 @@ haku.workspace = true
|
|||
log.workspace = true
|
||||
paste = "1.0.15"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
std = []
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
#![no_std]
|
||||
#![cfg_attr(not(feature = "std"), no_std)]
|
||||
|
||||
extern crate alloc;
|
||||
|
||||
|
@ -20,6 +20,7 @@ use haku::{
|
|||
use log::{debug, info};
|
||||
|
||||
pub mod logging;
|
||||
#[cfg(not(feature = "std"))]
|
||||
mod panicking;
|
||||
|
||||
#[global_allocator]
|
||||
|
|
|
@ -4,6 +4,7 @@ version = "0.1.0"
|
|||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
argon2 = "0.5.3"
|
||||
axum = { version = "0.7.5", features = ["macros", "ws"] }
|
||||
base64 = "0.22.1"
|
||||
chrono = "0.4.38"
|
||||
|
|
|
@ -34,7 +34,7 @@ struct NewUserParams {
|
|||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
enum NewUserResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Ok { user_id: String },
|
||||
Ok { user_id: String, secret: String },
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
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 {
|
||||
Ok(user_id) => (
|
||||
Ok(new_user) => (
|
||||
StatusCode::OK,
|
||||
Json(NewUserResponse::Ok {
|
||||
user_id: user_id.to_string(),
|
||||
user_id: new_user.user_id.to_string(),
|
||||
secret: new_user.secret,
|
||||
}),
|
||||
),
|
||||
Err(error) => (
|
||||
|
|
|
@ -10,6 +10,7 @@ use axum::{
|
|||
},
|
||||
response::Response,
|
||||
};
|
||||
use base64::Engine;
|
||||
use eyre::{bail, Context, OptionExt};
|
||||
use haku::value::Value;
|
||||
use schema::{
|
||||
|
@ -25,7 +26,7 @@ use tracing::{error, info, instrument};
|
|||
|
||||
use crate::{
|
||||
haku::{Haku, Limits},
|
||||
login::database::LoginStatus,
|
||||
login::{self, database::LoginStatus},
|
||||
schema::Vec2,
|
||||
wall::{
|
||||
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 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
|
||||
.dbs
|
||||
.login
|
||||
.log_in(user_id)
|
||||
.log_in(user_id, secret)
|
||||
.await
|
||||
.context("error while logging in")?
|
||||
{
|
||||
LoginStatus::ValidUser => (),
|
||||
LoginStatus::UserDoesNotExist => {
|
||||
LoginStatus::InvalidUser => {
|
||||
ws.send(to_message(&LoginResponse::UserDoesNotExist))
|
||||
.await?;
|
||||
return Ok(());
|
||||
|
|
|
@ -22,6 +22,7 @@ pub struct Error {
|
|||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoginRequest {
|
||||
pub user: UserId,
|
||||
pub secret: String,
|
||||
/// If null, a new wall is created.
|
||||
pub wall: Option<WallId>,
|
||||
pub init: UserInit,
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use argon2::{
|
||||
password_hash::{PasswordHasher, Salt, SaltString},
|
||||
Argon2, PasswordHash, PasswordVerifier,
|
||||
};
|
||||
use base64::Engine;
|
||||
use chrono::Utc;
|
||||
use eyre::{eyre, Context};
|
||||
use rand::SeedableRng;
|
||||
use rand::{RngCore, SeedableRng};
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tracing::instrument;
|
||||
|
@ -21,7 +26,7 @@ pub struct Database {
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LoginStatus {
|
||||
ValidUser,
|
||||
UserDoesNotExist,
|
||||
InvalidUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
@ -29,14 +34,23 @@ pub struct UserInfo {
|
|||
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 {
|
||||
NewUser {
|
||||
nickname: String,
|
||||
reply: oneshot::Sender<eyre::Result<UserId>>,
|
||||
reply: oneshot::Sender<eyre::Result<NewUser>>,
|
||||
},
|
||||
|
||||
LogIn {
|
||||
user_id: UserId,
|
||||
secret: Vec<u8>,
|
||||
reply: oneshot::Sender<LoginStatus>,
|
||||
},
|
||||
|
||||
|
@ -47,7 +61,11 @@ enum Command {
|
|||
}
|
||||
|
||||
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();
|
||||
self.command_tx
|
||||
.send(Command::NewUser {
|
||||
|
@ -59,10 +77,14 @@ impl Database {
|
|||
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();
|
||||
self.command_tx
|
||||
.send(Command::LogIn { user_id, reply: tx })
|
||||
.send(Command::LogIn {
|
||||
user_id,
|
||||
secret,
|
||||
reply: tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| eyre!("database is too contended"))?;
|
||||
rx.await.map_err(|_| eyre!("database is not available"))
|
||||
|
@ -90,6 +112,7 @@ pub fn start(settings: &Settings) -> eyre::Result<Database> {
|
|||
t_users (
|
||||
user_index INTEGER PRIMARY KEY,
|
||||
long_user_id BLOB NOT NULL,
|
||||
secret_hash BLOB NOT NULL,
|
||||
nickname TEXT 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 secret_rng = rand_chacha::ChaCha20Rng::from_entropy();
|
||||
let argon2 = Argon2::default();
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("login database thread".into())
|
||||
.spawn(move || {
|
||||
|
@ -107,8 +133,18 @@ pub fn start(settings: &Settings) -> eyre::Result<Database> {
|
|||
.prepare(
|
||||
r#"
|
||||
INSERT INTO t_users
|
||||
(long_user_id, nickname, last_login_timestamp)
|
||||
VALUES (?, ?, ?);
|
||||
(long_user_id, nickname, secret_hash, last_login_timestamp)
|
||||
VALUES (?, ?, ?, ?);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut s_get_secret = db
|
||||
.prepare(
|
||||
r#"
|
||||
SELECT secret_hash
|
||||
FROM t_users
|
||||
WHERE long_user_id = ?;
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
@ -137,21 +173,61 @@ pub fn start(settings: &Settings) -> eyre::Result<Database> {
|
|||
while let Some(command) = command_rx.blocking_recv() {
|
||||
match command {
|
||||
Command::NewUser { nickname, reply } => {
|
||||
let result = || -> eyre::Result<_> {
|
||||
let user_id = UserId::new(&mut user_id_rng);
|
||||
let result = s_insert_user
|
||||
.execute((user_id.0, nickname, Utc::now().timestamp()))
|
||||
.context("could not execute query");
|
||||
_ = reply.send(result.map(|_| user_id));
|
||||
|
||||
let mut secret = [0; Database::CURRENT_SECRET_LEN];
|
||||
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.
|
||||
let login_status =
|
||||
match s_log_in.execute((Utc::now().timestamp(), user_id.0)) {
|
||||
let result = || -> eyre::Result<_> {
|
||||
let secret_hash: String = s_get_secret
|
||||
.query_row((user_id.0,), |row| row.get(0))
|
||||
.context("no such user")?;
|
||||
|
||||
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::UserDoesNotExist,
|
||||
};
|
||||
_ = reply.send(login_status);
|
||||
Err(_) => LoginStatus::InvalidUser,
|
||||
});
|
||||
}
|
||||
|
||||
Command::UserInfo { user_id, reply } => {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 { ReticleCursor } from "./reticle-renderer.js";
|
||||
|
||||
|
@ -59,6 +59,7 @@ function readUrl() {
|
|||
|
||||
let session = await newSession(
|
||||
getUserId(),
|
||||
getLoginSecret(),
|
||||
urlData.wallId ?? localStorage.getItem("rkgk.mostRecentWallId"),
|
||||
{
|
||||
brush: brushEditor.code,
|
||||
|
|
|
@ -17,6 +17,10 @@ export function getUserId() {
|
|||
return loginStorage.userId;
|
||||
}
|
||||
|
||||
export function getLoginSecret() {
|
||||
return loginStorage.secret;
|
||||
}
|
||||
|
||||
export function waitForLogin() {
|
||||
return loggedInPromise;
|
||||
}
|
||||
|
@ -54,8 +58,8 @@ export async function registerUser(nickname) {
|
|||
};
|
||||
}
|
||||
|
||||
console.log(responseJson);
|
||||
loginStorage.userId = responseJson.userId;
|
||||
loginStorage.secret = responseJson.secret;
|
||||
console.info("user registered", loginStorage.userId);
|
||||
saveLoginStorage();
|
||||
resolveLoggedInPromise();
|
||||
|
@ -71,9 +75,10 @@ export async function registerUser(nickname) {
|
|||
}
|
||||
|
||||
class Session extends EventTarget {
|
||||
constructor(userId) {
|
||||
constructor(userId, secret) {
|
||||
super();
|
||||
this.userId = userId;
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
async #recvJson() {
|
||||
|
@ -138,6 +143,9 @@ class Session extends EventTarget {
|
|||
}
|
||||
|
||||
async joinInner(wallId, userInit) {
|
||||
let secret = this.secret;
|
||||
this.secret = null;
|
||||
|
||||
let version = await this.#recvJson();
|
||||
console.info("protocol version", version.version);
|
||||
// TODO: This should probably verify that the version is compatible.
|
||||
|
@ -149,11 +157,13 @@ class Session extends EventTarget {
|
|||
if (this.wallId == null) {
|
||||
this.#sendJson({
|
||||
user: this.userId,
|
||||
secret,
|
||||
init,
|
||||
});
|
||||
} else {
|
||||
this.#sendJson({
|
||||
user: this.userId,
|
||||
secret,
|
||||
wall: wallId,
|
||||
init,
|
||||
});
|
||||
|
@ -259,8 +269,8 @@ class Session extends EventTarget {
|
|||
}
|
||||
}
|
||||
|
||||
export async function newSession(userId, wallId, userInit) {
|
||||
let session = new Session(userId);
|
||||
export async function newSession(userId, secret, wallId, userInit) {
|
||||
let session = new Session(userId, secret);
|
||||
await session.join(wallId, userInit);
|
||||
return session;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue