use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use rusqlite::{params, Connection};
use crate::db::models::*;
use crate::error::LificError;
pub fn hash_password(password: &str) -> Result<String, LificError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| LificError::Internal(format!("password hashing failed: {e}")))?;
Ok(hash.to_string())
}
pub fn verify_password(password: &str, hash: &str) -> Result<bool, LificError> {
let parsed = PasswordHash::new(hash)
.map_err(|e| LificError::Internal(format!("invalid password hash: {e}")))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok())
}
pub fn create_user(conn: &Connection, input: &CreateUser) -> Result<User, LificError> {
let username = input.username.trim();
let email = input.email.trim().to_lowercase();
if username.is_empty() {
return Err(LificError::BadRequest("username cannot be empty".into()));
}
if email.is_empty() || !email.contains('@') {
return Err(LificError::BadRequest("invalid email address".into()));
}
if input.password.len() < 8 {
return Err(LificError::BadRequest(
"password must be at least 8 characters".into(),
));
}
if input.password.len() > 1024 {
return Err(LificError::BadRequest(
"password must be 1024 characters or fewer".into(),
));
}
let password_hash = hash_password(&input.password)?;
let display_name = input
.display_name
.as_deref()
.unwrap_or(username)
.to_string();
conn.execute(
"INSERT INTO users (username, email, password_hash, display_name, is_admin, is_bot)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
username,
email,
password_hash,
display_name,
input.is_admin,
input.is_bot,
],
)
.map_err(|e| match e {
rusqlite::Error::SqliteFailure(err, _)
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
LificError::BadRequest("an account with this username or email already exists".into())
}
other => other.into(),
})?;
let id = conn.last_insert_rowid();
get_user_by_id(conn, id)
}
pub fn get_user_by_id(conn: &Connection, id: i64) -> Result<User, LificError> {
conn.query_row(
"SELECT id, username, email, password_hash, display_name, is_admin, is_bot, created_at, updated_at
FROM users WHERE id = ?1",
params![id],
row_to_user,
)
.map_err(|e| match e {
rusqlite::Error::QueryReturnedNoRows => LificError::NotFound(format!("user {id} not found")),
other => other.into(),
})
}
pub fn get_user_by_username(conn: &Connection, username: &str) -> Result<User, LificError> {
conn.query_row(
"SELECT id, username, email, password_hash, display_name, is_admin, is_bot, created_at, updated_at
FROM users WHERE username = ?1 COLLATE NOCASE",
params![username],
row_to_user,
)
.map_err(|e| match e {
rusqlite::Error::QueryReturnedNoRows => {
LificError::NotFound(format!("user '{username}' not found"))
}
other => other.into(),
})
}
pub fn get_user_by_email(conn: &Connection, email: &str) -> Result<User, LificError> {
let email = email.trim().to_lowercase();
conn.query_row(
"SELECT id, username, email, password_hash, display_name, is_admin, is_bot, created_at, updated_at
FROM users WHERE email = ?1 COLLATE NOCASE",
params![email],
row_to_user,
)
.map_err(|e| match e {
rusqlite::Error::QueryReturnedNoRows => {
LificError::NotFound(format!("user with email '{email}' not found"))
}
other => other.into(),
})
}
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
pub fn authenticate(conn: &Connection, identity: &str, password: &str) -> Result<User, LificError> {
if password.len() > 1024 {
return Err(LificError::BadRequest(
"invalid username/email or password".into(),
));
}
let user = get_user_by_username(conn, identity).or_else(|_| get_user_by_email(conn, identity));
let (user, hash) = match user {
Ok(u) => {
let h = u.password_hash.clone();
(Some(u), h)
}
Err(_) => (None, DUMMY_HASH.to_string()),
};
let password_ok = verify_password(password, &hash).unwrap_or(false);
match user {
Some(u) if password_ok => Ok(u),
_ => Err(LificError::BadRequest(
"invalid username/email or password".into(),
)),
}
}
pub fn list_users(conn: &Connection) -> Result<Vec<User>, LificError> {
let mut stmt = conn.prepare(
"SELECT id, username, email, password_hash, display_name, is_admin, is_bot, created_at, updated_at
FROM users ORDER BY created_at",
)?;
let rows = stmt.query_map([], row_to_user)?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}
fn row_to_user(row: &rusqlite::Row) -> Result<User, rusqlite::Error> {
Ok(User {
id: row.get(0)?,
username: row.get(1)?,
email: row.get(2)?,
password_hash: row.get(3)?,
display_name: row.get(4)?,
is_admin: row.get(5)?,
is_bot: row.get(6)?,
created_at: row.get(7)?,
updated_at: row.get(8)?,
})
}
fn hash_session_token(token: &str) -> String {
use sha2::{Digest, Sha256};
let hash = Sha256::digest(token.as_bytes());
hash.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn create_session(
conn: &Connection,
user_id: i64,
duration_hours: Option<i64>,
) -> Result<Session, LificError> {
let hours = duration_hours.unwrap_or(24 * 7); let token = generate_session_token();
let token_hash = hash_session_token(&token);
conn.execute(
"INSERT INTO sessions (token, user_id, expires_at)
VALUES (?1, ?2, datetime('now', ?3))",
params![token_hash, user_id, format!("+{hours} hours")],
)?;
Ok(Session {
token,
user_id,
expires_at: conn.query_row(
"SELECT expires_at FROM sessions WHERE token = ?1",
params![token_hash],
|row| row.get(0),
)?,
created_at: conn.query_row(
"SELECT created_at FROM sessions WHERE token = ?1",
params![token_hash],
|row| row.get(0),
)?,
})
}
pub fn validate_session(conn: &Connection, token: &str) -> Result<User, LificError> {
let _ = conn.execute(
"DELETE FROM sessions WHERE expires_at < datetime('now')",
[],
);
let token_hash = hash_session_token(token);
let user_id: i64 = conn
.query_row(
"SELECT user_id FROM sessions WHERE token = ?1 AND expires_at > datetime('now')",
params![token_hash],
|row| row.get(0),
)
.map_err(|e| match e {
rusqlite::Error::QueryReturnedNoRows => {
LificError::BadRequest("invalid or expired session".into())
}
other => other.into(),
})?;
get_user_by_id(conn, user_id)
}
pub fn delete_session(conn: &Connection, token: &str) -> Result<(), LificError> {
let token_hash = hash_session_token(token);
conn.execute("DELETE FROM sessions WHERE token = ?1", params![token_hash])?;
Ok(())
}
#[allow(dead_code)]
pub fn delete_all_sessions(conn: &Connection, user_id: i64) -> Result<(), LificError> {
conn.execute("DELETE FROM sessions WHERE user_id = ?1", params![user_id])?;
Ok(())
}
fn generate_session_token() -> String {
let bytes: [u8; 32] = rand::random();
let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
format!("lific_sess_{hex}")
}
pub fn assign_key_to_user(
conn: &Connection,
key_name: &str,
user_id: i64,
) -> Result<(), LificError> {
let changed = conn.execute(
"UPDATE api_keys SET user_id = ?1 WHERE name = ?2 AND revoked = 0",
params![user_id, key_name],
)?;
if changed == 0 {
return Err(LificError::NotFound(format!(
"no active key named '{key_name}'"
)));
}
Ok(())
}
#[allow(dead_code)]
pub fn get_user_for_api_key(conn: &Connection, key_id: i64) -> Result<Option<User>, LificError> {
let user_id: Option<i64> = conn
.query_row(
"SELECT user_id FROM api_keys WHERE id = ?1",
params![key_id],
|row| row.get(0),
)
.map_err(|e| match e {
rusqlite::Error::QueryReturnedNoRows => {
LificError::NotFound("api key not found".into())
}
other => other.into(),
})?;
match user_id {
Some(uid) => Ok(Some(get_user_by_id(conn, uid)?)),
None => Ok(None),
}
}
pub fn create_bot_user(
conn: &Connection,
owner_id: i64,
bot_username: &str,
display_name: &str,
) -> Result<crate::db::models::User, LificError> {
let random_pw: [u8; 32] = rand::random();
let random_pw_hex: String = random_pw.iter().map(|b| format!("{b:02x}")).collect();
let password_hash = hash_password(&random_pw_hex)?;
conn.execute(
"INSERT INTO users (username, email, password_hash, display_name, is_admin, is_bot, owner_id)
VALUES (?1, ?2, ?3, ?4, 0, 1, ?5)",
params![
bot_username,
format!("{bot_username}@bot.local"),
password_hash,
display_name,
owner_id,
],
)
.map_err(|e| match e {
rusqlite::Error::SqliteFailure(err, _)
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
LificError::BadRequest(format!(
"this tool is already connected (bot '{bot_username}' exists)"
))
}
other => other.into(),
})?;
let bot_user_id = conn.last_insert_rowid();
get_user_by_id(conn, bot_user_id)
}
pub fn set_admin(conn: &Connection, username: &str, is_admin: bool) -> Result<(), LificError> {
let changed = conn.execute(
"UPDATE users SET is_admin = ?1, updated_at = datetime('now') WHERE username = ?2 COLLATE NOCASE",
params![is_admin, username],
)?;
if changed == 0 {
return Err(LificError::NotFound(format!("user '{username}' not found")));
}
Ok(())
}
pub fn find_bot_by_username(
conn: &Connection,
username: &str,
) -> Result<Option<crate::db::models::User>, LificError> {
match conn.query_row(
"SELECT id, username, email, password_hash, display_name, is_admin, is_bot, created_at, updated_at
FROM users WHERE username = ?1 AND is_bot = 1",
params![username],
row_to_user,
) {
Ok(user) => Ok(Some(user)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn bot_has_active_key(conn: &Connection, bot_id: i64) -> Result<bool, LificError> {
let has: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM api_keys WHERE user_id = ?1 AND revoked = 0",
params![bot_id],
|row| row.get(0),
)
.unwrap_or(false);
Ok(has)
}
pub fn list_bots(
conn: &Connection,
owner_id: i64,
) -> Result<Vec<crate::db::models::Bot>, LificError> {
let mut stmt = conn.prepare(
"SELECT u.id, u.username, u.display_name, u.owner_id, u.created_at,
EXISTS(SELECT 1 FROM api_keys k WHERE k.user_id = u.id AND k.revoked = 0) as has_key
FROM users u
WHERE u.is_bot = 1 AND u.owner_id = ?1
ORDER BY u.created_at DESC",
)?;
let rows = stmt.query_map(params![owner_id], |row| {
Ok(crate::db::models::Bot {
id: row.get(0)?,
username: row.get(1)?,
display_name: row.get(2)?,
owner_id: row.get(3)?,
created_at: row.get(4)?,
has_active_key: row.get(5)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}
pub fn disconnect_bot(
conn: &Connection,
bot_id: i64,
requester_id: i64,
is_admin: bool,
) -> Result<(), LificError> {
let owner_id: Option<i64> = conn
.query_row(
"SELECT owner_id FROM users WHERE id = ?1 AND is_bot = 1",
params![bot_id],
|row| row.get(0),
)
.map_err(|_| LificError::NotFound("bot not found".into()))?;
if owner_id != Some(requester_id) && !is_admin {
return Err(LificError::BadRequest(
"you can only disconnect your own bots".into(),
));
}
conn.execute(
"UPDATE api_keys SET revoked = 1 WHERE user_id = ?1 AND revoked = 0",
params![bot_id],
)?;
Ok(())
}
pub fn delete_bot(
conn: &Connection,
bot_id: i64,
requester_id: i64,
is_admin: bool,
) -> Result<(), LificError> {
let owner_id: Option<i64> = conn
.query_row(
"SELECT owner_id FROM users WHERE id = ?1 AND is_bot = 1",
params![bot_id],
|row| row.get(0),
)
.map_err(|_| LificError::NotFound("bot not found".into()))?;
if owner_id != Some(requester_id) && !is_admin {
return Err(LificError::BadRequest(
"you can only delete your own bots".into(),
));
}
conn.execute("DELETE FROM api_keys WHERE user_id = ?1", params![bot_id])?;
conn.execute("DELETE FROM comments WHERE user_id = ?1", params![bot_id])?;
let changed = conn.execute(
"DELETE FROM users WHERE id = ?1 AND is_bot = 1",
params![bot_id],
)?;
if changed == 0 {
return Err(LificError::NotFound("bot not found".into()));
}
Ok(())
}
pub fn list_user_keys(
conn: &Connection,
user_id: i64,
) -> Result<Vec<crate::db::models::UserApiKey>, LificError> {
let mut stmt = conn.prepare(
"SELECT id, name, created_at, expires_at, revoked
FROM api_keys WHERE user_id = ?1
ORDER BY created_at DESC",
)?;
let rows = stmt.query_map(params![user_id], |row| {
Ok(crate::db::models::UserApiKey {
id: row.get(0)?,
name: row.get(1)?,
created_at: row.get(2)?,
expires_at: row.get(3)?,
revoked: row.get(4)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}
pub fn revoke_user_key(
conn: &Connection,
key_id: i64,
user_id: i64,
is_admin: bool,
) -> Result<(), LificError> {
let changed = if is_admin {
conn.execute(
"UPDATE api_keys SET revoked = 1 WHERE id = ?1 AND revoked = 0",
params![key_id],
)?
} else {
conn.execute(
"UPDATE api_keys SET revoked = 1 WHERE id = ?1 AND user_id = ?2 AND revoked = 0",
params![key_id, user_id],
)?
};
if changed == 0 {
return Err(LificError::NotFound(
"key not found or already revoked".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
fn test_db() -> db::DbPool {
db::open_memory().expect("test db")
}
fn test_create_user(conn: &Connection) -> User {
create_user(
conn,
&CreateUser {
username: "blake".into(),
email: "blake@example.com".into(),
password: "securepassword123".into(),
display_name: Some("Blake".into()),
is_admin: true,
is_bot: false,
},
)
.expect("create user")
}
#[test]
fn password_hash_roundtrip() {
let hash = hash_password("my-secret-pass").unwrap();
assert!(hash.starts_with("$argon2"));
assert!(verify_password("my-secret-pass", &hash).unwrap());
assert!(!verify_password("wrong-pass", &hash).unwrap());
}
#[test]
fn create_and_get_user() {
let pool = test_db();
let conn = pool.write().unwrap();
let user = test_create_user(&conn);
assert_eq!(user.username, "blake");
assert_eq!(user.email, "blake@example.com");
assert_eq!(user.display_name, "Blake");
assert!(user.is_admin);
assert!(!user.is_bot);
assert!(user.password_hash.starts_with("$argon2"));
let fetched = get_user_by_id(&conn, user.id).unwrap();
assert_eq!(fetched.username, "blake");
let fetched = get_user_by_username(&conn, "Blake").unwrap();
assert_eq!(fetched.id, user.id);
let fetched = get_user_by_email(&conn, "BLAKE@EXAMPLE.COM").unwrap();
assert_eq!(fetched.id, user.id);
}
#[test]
fn duplicate_username_rejected() {
let pool = test_db();
let conn = pool.write().unwrap();
test_create_user(&conn);
let result = create_user(
&conn,
&CreateUser {
username: "blake".into(),
email: "other@example.com".into(),
password: "anotherpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already exists"));
}
#[test]
fn duplicate_email_rejected() {
let pool = test_db();
let conn = pool.write().unwrap();
test_create_user(&conn);
let result = create_user(
&conn,
&CreateUser {
username: "other".into(),
email: "blake@example.com".into(),
password: "anotherpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
);
assert!(result.is_err());
}
#[test]
fn short_password_rejected() {
let pool = test_db();
let conn = pool.write().unwrap();
let result = create_user(
&conn,
&CreateUser {
username: "test".into(),
email: "test@example.com".into(),
password: "short".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("8 characters"));
}
#[test]
fn oversized_password_rejected() {
let pool = test_db();
let conn = pool.write().unwrap();
let long_pw = "a".repeat(1025);
let result = create_user(
&conn,
&CreateUser {
username: "test".into(),
email: "test@example.com".into(),
password: long_pw,
display_name: None,
is_admin: false,
is_bot: false,
},
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("1024"));
}
#[test]
fn authenticate_correct_password() {
let pool = test_db();
let conn = pool.write().unwrap();
test_create_user(&conn);
let user = authenticate(&conn, "blake", "securepassword123").unwrap();
assert_eq!(user.username, "blake");
let user = authenticate(&conn, "blake@example.com", "securepassword123").unwrap();
assert_eq!(user.username, "blake");
}
#[test]
fn authenticate_wrong_password_rejected() {
let pool = test_db();
let conn = pool.write().unwrap();
test_create_user(&conn);
let result = authenticate(&conn, "blake", "wrongpassword123");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("invalid"));
}
#[test]
fn authenticate_nonexistent_user_rejected() {
let pool = test_db();
let conn = pool.write().unwrap();
let result = authenticate(&conn, "nobody", "password12345678");
assert!(result.is_err());
}
#[test]
fn list_users_returns_all() {
let pool = test_db();
let conn = pool.write().unwrap();
test_create_user(&conn);
create_user(
&conn,
&CreateUser {
username: "ada".into(),
email: "ada@example.com".into(),
password: "adaspassword123".into(),
display_name: Some("Ada".into()),
is_admin: false,
is_bot: true,
},
)
.unwrap();
let users = list_users(&conn).unwrap();
assert_eq!(users.len(), 2);
}
#[test]
fn display_name_defaults_to_username() {
let pool = test_db();
let conn = pool.write().unwrap();
let user = create_user(
&conn,
&CreateUser {
username: "noname".into(),
email: "noname@example.com".into(),
password: "password12345678".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
assert_eq!(user.display_name, "noname");
}
#[test]
fn session_create_and_validate() {
let pool = test_db();
let conn = pool.write().unwrap();
let user = test_create_user(&conn);
let session = create_session(&conn, user.id, None).unwrap();
assert!(session.token.starts_with("lific_sess_"));
assert_eq!(session.user_id, user.id);
let validated_user = validate_session(&conn, &session.token).unwrap();
assert_eq!(validated_user.id, user.id);
assert_eq!(validated_user.username, "blake");
}
#[test]
fn session_invalid_token_rejected() {
let pool = test_db();
let conn = pool.write().unwrap();
let result = validate_session(&conn, "lific_sess_nonexistent");
assert!(result.is_err());
}
#[test]
fn session_expired_rejected() {
let pool = test_db();
let conn = pool.write().unwrap();
let user = test_create_user(&conn);
let token = generate_session_token();
conn.execute(
"INSERT INTO sessions (token, user_id, expires_at)
VALUES (?1, ?2, datetime('now', '-1 hour'))",
params![token, user.id],
)
.unwrap();
let result = validate_session(&conn, &token);
assert!(result.is_err());
}
#[test]
fn session_delete_logout() {
let pool = test_db();
let conn = pool.write().unwrap();
let user = test_create_user(&conn);
let session = create_session(&conn, user.id, None).unwrap();
assert!(validate_session(&conn, &session.token).is_ok());
delete_session(&conn, &session.token).unwrap();
assert!(validate_session(&conn, &session.token).is_err());
}
#[test]
fn session_delete_all_for_user() {
let pool = test_db();
let conn = pool.write().unwrap();
let user = test_create_user(&conn);
let s1 = create_session(&conn, user.id, None).unwrap();
let s2 = create_session(&conn, user.id, None).unwrap();
delete_all_sessions(&conn, user.id).unwrap();
assert!(validate_session(&conn, &s1.token).is_err());
assert!(validate_session(&conn, &s2.token).is_err());
}
#[test]
fn assign_key_to_user_works() {
let pool = test_db();
let conn = pool.write().unwrap();
let user = test_create_user(&conn);
conn.execute(
"INSERT INTO api_keys (name, key_hash) VALUES ('opencode', 'fakehash')",
[],
)
.unwrap();
let key_id: i64 = conn
.query_row(
"SELECT id FROM api_keys WHERE name = 'opencode'",
[],
|row| row.get(0),
)
.unwrap();
let owner = get_user_for_api_key(&conn, key_id).unwrap();
assert!(owner.is_none());
assign_key_to_user(&conn, "opencode", user.id).unwrap();
let owner = get_user_for_api_key(&conn, key_id).unwrap();
assert!(owner.is_some());
assert_eq!(owner.unwrap().username, "blake");
}
#[test]
fn assign_nonexistent_key_fails() {
let pool = test_db();
let conn = pool.write().unwrap();
let user = test_create_user(&conn);
let result = assign_key_to_user(&conn, "nope", user.id);
assert!(result.is_err());
}
}