mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-04-01 21:13:09 +00:00
Refactor/project structure (#3085)
* refactor project structure * environment-based default registry * fix tests * update build container * use docker platform for iso build emulation * simplify compat * Fix docker platform spec in run-compat.sh * handle riscv compat * fix bug with dep error exists attr * undo removal of sorting * use qemu for iso stage --------- Co-authored-by: Mariusz Kogen <k0gen@pm.me> Co-authored-by: Matt Hill <mattnine@protonmail.com>
This commit is contained in:
500
core/src/auth.rs
Normal file
500
core/src/auth.rs
Normal file
@@ -0,0 +1,500 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use clap::Parser;
|
||||
use color_eyre::eyre::eyre;
|
||||
use imbl_value::{InternedString, json};
|
||||
use itertools::Itertools;
|
||||
use josekit::jwk::Jwk;
|
||||
use rpc_toolkit::yajrc::RpcError;
|
||||
use rpc_toolkit::{CallRemote, Context, HandlerArgs, HandlerExt, ParentHandler, from_fn_async};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tracing::instrument;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::context::{CliContext, RpcContext};
|
||||
use crate::middleware::auth::session::{
|
||||
AsLogoutSessionId, HasLoggedOutSessions, HashSessionToken, LoginRes, SessionAuthContext,
|
||||
};
|
||||
use crate::prelude::*;
|
||||
use crate::util::crypto::EncryptedWire;
|
||||
use crate::util::io::create_file_mod;
|
||||
use crate::util::serde::{HandlerExtSerde, WithIoFormat, display_serializable};
|
||||
use crate::{Error, ResultExt, ensure_code};
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, TS)]
|
||||
pub struct Sessions(pub BTreeMap<InternedString, Session>);
|
||||
impl Sessions {
|
||||
pub fn new() -> Self {
|
||||
Self(BTreeMap::new())
|
||||
}
|
||||
}
|
||||
impl Map for Sessions {
|
||||
type Key = InternedString;
|
||||
type Value = Session;
|
||||
fn key_str(key: &Self::Key) -> Result<impl AsRef<str>, Error> {
|
||||
Ok(key)
|
||||
}
|
||||
fn key_string(key: &Self::Key) -> Result<InternedString, Error> {
|
||||
Ok(key.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write_shadow(password: &str) -> Result<(), Error> {
|
||||
let hash: String = sha_crypt::sha512_simple(password, &sha_crypt::Sha512Params::default())
|
||||
.map_err(|e| Error::new(eyre!("{e:?}"), ErrorKind::Serialization))?;
|
||||
let shadow_contents = tokio::fs::read_to_string("/etc/shadow").await?;
|
||||
let mut shadow_file =
|
||||
create_file_mod("/media/startos/config/overlay/etc/shadow", 0o640).await?;
|
||||
for line in shadow_contents.lines() {
|
||||
match line.split_once(":") {
|
||||
Some((user, rest)) if user == "start9" || user == "kiosk" => {
|
||||
let (_, rest) = rest.split_once(":").ok_or_else(|| {
|
||||
Error::new(eyre!("malformed /etc/shadow"), ErrorKind::ParseSysInfo)
|
||||
})?;
|
||||
shadow_file
|
||||
.write_all(format!("{user}:{hash}:{rest}\n").as_bytes())
|
||||
.await?;
|
||||
}
|
||||
_ => {
|
||||
shadow_file.write_all(line.as_bytes()).await?;
|
||||
shadow_file.write_all(b"\n").await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
shadow_file.sync_all().await?;
|
||||
tokio::fs::copy("/media/startos/config/overlay/etc/shadow", "/etc/shadow").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(untagged)]
|
||||
#[ts(export)]
|
||||
pub enum PasswordType {
|
||||
EncryptedWire(EncryptedWire),
|
||||
String(String),
|
||||
}
|
||||
impl PasswordType {
|
||||
pub fn decrypt(self, current_secret: impl AsRef<Jwk>) -> Result<String, Error> {
|
||||
match self {
|
||||
PasswordType::String(x) => Ok(x),
|
||||
PasswordType::EncryptedWire(x) => x.decrypt(current_secret).ok_or_else(|| {
|
||||
Error::new(
|
||||
color_eyre::eyre::eyre!("Couldn't decode password"),
|
||||
crate::ErrorKind::Unknown,
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Default for PasswordType {
|
||||
fn default() -> Self {
|
||||
PasswordType::String(String::default())
|
||||
}
|
||||
}
|
||||
impl std::fmt::Debug for PasswordType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "<REDACTED_PASSWORD>")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for PasswordType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(match serde_json::from_str(s) {
|
||||
Ok(a) => a,
|
||||
Err(_) => PasswordType::String(s.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
pub fn auth<C: Context, AC: SessionAuthContext>() -> ParentHandler<C>
|
||||
where
|
||||
CliContext: CallRemote<AC>,
|
||||
{
|
||||
ParentHandler::new()
|
||||
.subcommand(
|
||||
"login",
|
||||
from_fn_async(login_impl::<AC>)
|
||||
.with_metadata("login", Value::Bool(true))
|
||||
.no_cli(),
|
||||
)
|
||||
.subcommand(
|
||||
"login",
|
||||
from_fn_async(cli_login::<AC>)
|
||||
.no_display()
|
||||
.with_about("Log in a new auth session"),
|
||||
)
|
||||
.subcommand(
|
||||
"logout",
|
||||
from_fn_async(logout::<AC>)
|
||||
.with_metadata("get_session", Value::Bool(true))
|
||||
.no_display()
|
||||
.with_about("Log out of current auth session")
|
||||
.with_call_remote::<CliContext>(),
|
||||
)
|
||||
.subcommand(
|
||||
"session",
|
||||
session::<C, AC>().with_about("List or kill auth sessions"),
|
||||
)
|
||||
.subcommand(
|
||||
"reset-password",
|
||||
from_fn_async(reset_password_impl).no_cli(),
|
||||
)
|
||||
.subcommand(
|
||||
"reset-password",
|
||||
from_fn_async(cli_reset_password)
|
||||
.no_display()
|
||||
.with_about("Reset password"),
|
||||
)
|
||||
.subcommand(
|
||||
"get-pubkey",
|
||||
from_fn_async(get_pubkey)
|
||||
.with_metadata("authenticated", Value::Bool(false))
|
||||
.no_display()
|
||||
.with_about("Get public key derived from server private key")
|
||||
.with_call_remote::<CliContext>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gen_pwd() {
|
||||
println!(
|
||||
"{:?}",
|
||||
argon2::hash_encoded(
|
||||
b"testing1234",
|
||||
&rand::random::<[u8; 16]>()[..],
|
||||
&argon2::Config::rfc9106_low_mem()
|
||||
)
|
||||
.unwrap()
|
||||
)
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn cli_login<C: SessionAuthContext>(
|
||||
HandlerArgs {
|
||||
context: ctx,
|
||||
parent_method,
|
||||
method,
|
||||
..
|
||||
}: HandlerArgs<CliContext>,
|
||||
) -> Result<(), RpcError>
|
||||
where
|
||||
CliContext: CallRemote<C>,
|
||||
{
|
||||
let password = rpassword::prompt_password("Password: ")?;
|
||||
|
||||
ctx.call_remote::<C>(
|
||||
&parent_method.into_iter().chain(method).join("."),
|
||||
json!({
|
||||
"password": password,
|
||||
"metadata": {
|
||||
"platforms": ["cli"],
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_password(hash: &str, password: &str) -> Result<(), Error> {
|
||||
ensure_code!(
|
||||
argon2::verify_encoded(&hash, password.as_bytes()).map_err(|_| {
|
||||
Error::new(
|
||||
eyre!("Password Incorrect"),
|
||||
crate::ErrorKind::IncorrectPassword,
|
||||
)
|
||||
})?,
|
||||
crate::ErrorKind::IncorrectPassword,
|
||||
"Password Incorrect"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct LoginParams {
|
||||
password: String,
|
||||
#[ts(skip)]
|
||||
#[serde(rename = "__Auth_userAgent")] // from Auth middleware
|
||||
user_agent: Option<String>,
|
||||
#[serde(default)]
|
||||
ephemeral: bool,
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn login_impl<C: SessionAuthContext>(
|
||||
ctx: C,
|
||||
LoginParams {
|
||||
password,
|
||||
user_agent,
|
||||
ephemeral,
|
||||
}: LoginParams,
|
||||
) -> Result<LoginRes, Error> {
|
||||
let tok = if ephemeral {
|
||||
C::check_password(&ctx.db().peek().await, &password)?;
|
||||
let hash_token = HashSessionToken::new();
|
||||
ctx.ephemeral_sessions().mutate(|s| {
|
||||
s.0.insert(
|
||||
hash_token.hashed().clone(),
|
||||
Session {
|
||||
logged_in: Utc::now(),
|
||||
last_active: Utc::now(),
|
||||
user_agent,
|
||||
},
|
||||
)
|
||||
});
|
||||
Ok(hash_token.to_login_res())
|
||||
} else {
|
||||
ctx.db()
|
||||
.mutate(|db| {
|
||||
C::check_password(db, &password)?;
|
||||
let hash_token = HashSessionToken::new();
|
||||
C::access_sessions(db).insert(
|
||||
hash_token.hashed(),
|
||||
&Session {
|
||||
logged_in: Utc::now(),
|
||||
last_active: Utc::now(),
|
||||
user_agent,
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(hash_token.to_login_res())
|
||||
})
|
||||
.await
|
||||
.result
|
||||
}?;
|
||||
|
||||
ctx.post_login_hook(&password).await?;
|
||||
|
||||
Ok(tok)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Parser, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[command(rename_all = "kebab-case")]
|
||||
pub struct LogoutParams {
|
||||
#[ts(skip)]
|
||||
#[serde(rename = "__Auth_session")] // from Auth middleware
|
||||
session: InternedString,
|
||||
}
|
||||
|
||||
pub async fn logout<C: SessionAuthContext>(
|
||||
ctx: C,
|
||||
LogoutParams { session }: LogoutParams,
|
||||
) -> Result<Option<HasLoggedOutSessions>, Error> {
|
||||
Ok(Some(
|
||||
HasLoggedOutSessions::new(vec![HashSessionToken::from_token(session)], &ctx).await?,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct Session {
|
||||
#[ts(type = "string")]
|
||||
pub logged_in: DateTime<Utc>,
|
||||
#[ts(type = "string")]
|
||||
pub last_active: DateTime<Utc>,
|
||||
pub user_agent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct SessionList {
|
||||
#[ts(type = "string | null")]
|
||||
current: Option<InternedString>,
|
||||
sessions: Sessions,
|
||||
}
|
||||
|
||||
pub fn session<C: Context, AC: SessionAuthContext>() -> ParentHandler<C>
|
||||
where
|
||||
CliContext: CallRemote<AC>,
|
||||
{
|
||||
ParentHandler::new()
|
||||
.subcommand(
|
||||
"list",
|
||||
from_fn_async(list::<AC>)
|
||||
.with_metadata("get_session", Value::Bool(true))
|
||||
.with_display_serializable()
|
||||
.with_custom_display_fn(|handle, result| display_sessions(handle.params, result))
|
||||
.with_about("Display all auth sessions")
|
||||
.with_call_remote::<CliContext>(),
|
||||
)
|
||||
.subcommand(
|
||||
"kill",
|
||||
from_fn_async(kill::<AC>)
|
||||
.no_display()
|
||||
.with_about("Terminate existing auth session(s)")
|
||||
.with_call_remote::<CliContext>(),
|
||||
)
|
||||
}
|
||||
|
||||
fn display_sessions(params: WithIoFormat<ListParams>, arg: SessionList) -> Result<(), Error> {
|
||||
use prettytable::*;
|
||||
|
||||
if let Some(format) = params.format {
|
||||
return display_serializable(format, arg);
|
||||
}
|
||||
|
||||
let mut table = Table::new();
|
||||
table.add_row(row![bc =>
|
||||
"ID",
|
||||
"LOGGED IN",
|
||||
"LAST ACTIVE",
|
||||
"USER AGENT",
|
||||
]);
|
||||
for (id, session) in arg.sessions.0 {
|
||||
let mut row = row![
|
||||
&id,
|
||||
&format!("{}", session.logged_in),
|
||||
&format!("{}", session.last_active),
|
||||
session.user_agent.as_deref().unwrap_or("N/A"),
|
||||
];
|
||||
if Some(id) == arg.current {
|
||||
row.iter_mut()
|
||||
.map(|c| c.style(Attr::ForegroundColor(color::GREEN)))
|
||||
.collect::<()>()
|
||||
}
|
||||
table.add_row(row);
|
||||
}
|
||||
table.print_tty(false)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Parser, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[command(rename_all = "kebab-case")]
|
||||
pub struct ListParams {
|
||||
#[arg(skip)]
|
||||
#[ts(skip)]
|
||||
#[serde(rename = "__Auth_session")] // from Auth middleware
|
||||
session: Option<InternedString>,
|
||||
}
|
||||
|
||||
// #[command(display(display_sessions))]
|
||||
#[instrument(skip_all)]
|
||||
pub async fn list<C: SessionAuthContext>(
|
||||
ctx: C,
|
||||
ListParams { session, .. }: ListParams,
|
||||
) -> Result<SessionList, Error> {
|
||||
let mut sessions = C::access_sessions(&mut ctx.db().peek().await).de()?;
|
||||
ctx.ephemeral_sessions().peek(|s| {
|
||||
sessions
|
||||
.0
|
||||
.extend(s.0.iter().map(|(k, v)| (k.clone(), v.clone())))
|
||||
});
|
||||
Ok(SessionList {
|
||||
current: session,
|
||||
sessions,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct KillSessionId(InternedString);
|
||||
|
||||
impl KillSessionId {
|
||||
fn new(id: String) -> Self {
|
||||
Self(InternedString::from(id))
|
||||
}
|
||||
}
|
||||
|
||||
impl AsLogoutSessionId for KillSessionId {
|
||||
fn as_logout_session_id(self) -> InternedString {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Parser, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[command(rename_all = "kebab-case")]
|
||||
pub struct KillParams {
|
||||
ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn kill<C: SessionAuthContext>(
|
||||
ctx: C,
|
||||
KillParams { ids }: KillParams,
|
||||
) -> Result<(), Error> {
|
||||
HasLoggedOutSessions::new(ids.into_iter().map(KillSessionId::new), &ctx).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Parser, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[command(rename_all = "kebab-case")]
|
||||
pub struct ResetPasswordParams {
|
||||
old_password: Option<PasswordType>,
|
||||
new_password: Option<PasswordType>,
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn cli_reset_password(
|
||||
HandlerArgs {
|
||||
context: ctx,
|
||||
parent_method,
|
||||
method,
|
||||
..
|
||||
}: HandlerArgs<CliContext>,
|
||||
) -> Result<(), RpcError> {
|
||||
let old_password = rpassword::prompt_password("Current Password: ")?;
|
||||
|
||||
let new_password = {
|
||||
let new_password = rpassword::prompt_password("New Password: ")?;
|
||||
if new_password != rpassword::prompt_password("Confirm: ")? {
|
||||
return Err(Error::new(
|
||||
eyre!("Passwords do not match"),
|
||||
crate::ErrorKind::IncorrectPassword,
|
||||
)
|
||||
.into());
|
||||
}
|
||||
new_password
|
||||
};
|
||||
|
||||
ctx.call_remote::<RpcContext>(
|
||||
&parent_method.into_iter().chain(method).join("."),
|
||||
imbl_value::json!({ "old-password": old_password, "new-password": new_password }),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn reset_password_impl(
|
||||
ctx: RpcContext,
|
||||
ResetPasswordParams {
|
||||
old_password,
|
||||
new_password,
|
||||
}: ResetPasswordParams,
|
||||
) -> Result<(), Error> {
|
||||
let old_password = old_password.unwrap_or_default().decrypt(&ctx)?;
|
||||
let new_password = new_password.unwrap_or_default().decrypt(&ctx)?;
|
||||
|
||||
let account = ctx.account.mutate(|account| {
|
||||
if !argon2::verify_encoded(&account.password, old_password.as_bytes())
|
||||
.with_kind(crate::ErrorKind::IncorrectPassword)?
|
||||
{
|
||||
return Err(Error::new(
|
||||
eyre!("Incorrect Password"),
|
||||
crate::ErrorKind::IncorrectPassword,
|
||||
));
|
||||
}
|
||||
account.set_password(&new_password)?;
|
||||
Ok(account.clone())
|
||||
})?;
|
||||
ctx.db.mutate(|d| account.save(d)).await.result
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn get_pubkey(ctx: RpcContext) -> Result<Jwk, RpcError> {
|
||||
let secret = <RpcContext as AsRef<Jwk>>::as_ref(&ctx).clone();
|
||||
let pub_key = secret.to_public_key()?;
|
||||
Ok(pub_key)
|
||||
}
|
||||
Reference in New Issue
Block a user