Skip to content
Merged
21 changes: 21 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion crates/temps-agents/src/sandbox/docker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,13 +314,21 @@ COPY --from=git-credential-builder /build/target/release/temps-git-credential-da
RUN chmod 0755 /usr/local/bin/temps-git-credential-helper /usr/local/bin/temps-git-credential-daemon \
&& mkdir -p /run/temps-git \
&& chown temps-git:git-users /run/temps-git \
&& chmod 0750 /run/temps-git \
&& chmod 2750 /run/temps-git \
&& mkdir -p /etc/temps \
&& chown temps-git:git-users /etc/temps \
&& chmod 0710 /etc/temps \
&& touch /etc/temps/credential-daemon.env \
&& chown temps-git:temps-git /etc/temps/credential-daemon.env \
&& chmod 0600 /etc/temps/credential-daemon.env
# Mode 2750 on /run/temps-git/ sets the setgid bit on the directory.
# Without it, files (including the IPC socket) created inside inherit
# the *creating process's egid* — `temps-git`'s primary group, which
# is also `temps-git`. With setgid, new files inherit the parent dir's
# group (`git-users`) instead, which is what the user shell needs for
# `connect()` to succeed under mode 0660. Belt-and-braces: the daemon
# also explicitly chgrp's the socket on bind, so existing images
# without the setgid bit still recover after a daemon restart.
# System-wide git config: route every HTTPS git auth request through
# the credential helper. `useHttpPath=true` is mandatory — without it
# git omits the `path=` field, and the daemon can't tell what repo is
Expand Down
36 changes: 35 additions & 1 deletion crates/temps-auth/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,24 @@ pub struct LogoutAudit {
pub username: String,
}

// Password reset audit
// Password reset audit (out-of-band email-link flow)
#[derive(Debug, Clone, Serialize)]
pub struct PasswordResetAudit {
pub context: AuditContext,
pub username: String,
}

// In-app password change for an authenticated user. `other_sessions_revoked`
// reflects whether the operator opted to invalidate every other session on
// submit; useful for auditors trying to reconstruct "did this user lose
// access on every device on date X" after a credential rotation.
#[derive(Debug, Clone, Serialize)]
pub struct PasswordChangedAudit {
pub context: AuditContext,
pub username: String,
pub other_sessions_revoked: bool,
}

// Email verification audit
#[derive(Debug, Clone, Serialize)]
pub struct EmailVerifiedAudit {
Expand Down Expand Up @@ -384,6 +395,29 @@ impl AuditOperation for PasswordResetAudit {
}
}

impl AuditOperation for PasswordChangedAudit {
fn operation_type(&self) -> String {
"PASSWORD_CHANGED".to_string()
}

fn user_id(&self) -> i32 {
self.context.user_id
}

fn ip_address(&self) -> Option<String> {
self.context.ip_address.clone()
}

fn user_agent(&self) -> &str {
&self.context.user_agent
}

fn serialize(&self) -> Result<String> {
serde_json::to_string(self)
.map_err(|e| anyhow::anyhow!("Failed to serialize audit operation {}", e))
}
}

impl AuditOperation for EmailVerifiedAudit {
fn operation_type(&self) -> String {
"EMAIL_VERIFIED".to_string()
Expand Down
148 changes: 147 additions & 1 deletion crates/temps-auth/src/auth_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use std::sync::Arc;
use temps_core::notifications::DynNotificationService;
use thiserror::Error;
use totp_rs::Secret;
use tracing::{debug, error, warn};
use tracing::{debug, error, info, warn};
use uuid::Uuid;
const DEFAULT_EXTERNAL_URL: &str = "http://localhost:8000";
#[derive(Serialize)]
Expand Down Expand Up @@ -51,6 +51,22 @@ pub enum AuthError {
InternalServerError(String),
#[error("Generic error: {0}")]
GenericError(String),

// Password-change-specific errors. Distinct variants so the handler can
// map each one to a meaningful HTTP status / problem type instead of
// collapsing everything to 500.
#[error("Current password is incorrect")]
InvalidCurrentPassword,
#[error("MFA code is required for this account")]
MfaCodeRequired,
#[error("MFA code is invalid")]
InvalidMfaCode,
#[error("New password must differ from the current password")]
SamePassword,
#[error("Password complexity check failed: {0}")]
WeakPassword(String),
#[error("Account has no password set (likely a SSO/magic-link-only user)")]
NoPasswordSet,
}

impl From<sea_orm::DbErr> for AuthError {
Expand Down Expand Up @@ -564,6 +580,136 @@ impl AuthService {
Ok(updated_user)
}

/// In-app password change for an authenticated user.
///
/// Distinct from [`reset_password`] — that flow runs out-of-band via an
/// email link and trusts the token as proof of identity. This flow runs
/// while the user is already logged in and uses the current password +
/// (when applicable) an MFA code as the re-auth gate.
///
/// Caller must pass the encrypted session token from the request cookie
/// in `current_session_token` so we can preserve that one session when
/// `revoke_other_sessions` is true. The decrypted form is stored in the
/// `sessions` table; the handler decrypts before passing it in.
pub async fn change_password_self(
&self,
user_id: i32,
current_password: &str,
new_password: &str,
mfa_code: Option<&str>,
revoke_other_sessions: bool,
current_session_token: Option<&str>,
) -> Result<temps_entities::users::Model, AuthError> {
let user = temps_entities::users::Entity::find_by_id(user_id)
.one(self.db.as_ref())
.await?
.ok_or_else(|| AuthError::NotFound(format!("User {} not found", user_id)))?;

// 1. Account must have a password set. SSO/magic-link-only users
// get a friendly error instead of a generic 401.
let stored_hash = user
.password_hash
.as_ref()
.ok_or(AuthError::NoPasswordSet)?;

// 2. Verify current password (Argon2 only; legacy bcrypt rows would
// already have been migrated by login).
if !stored_hash.starts_with("$argon2") {
error!(
"User {} has unsupported password hash format during change",
user_id
);
return Err(AuthError::InvalidCurrentPassword);
}
use argon2::PasswordVerifier;
let parsed = argon2::password_hash::PasswordHash::new(stored_hash)
.map_err(|_| AuthError::InvalidCurrentPassword)?;
if argon2::Argon2::default()
.verify_password(current_password.as_bytes(), &parsed)
.is_err()
{
warn!("Password-change re-auth failed for user {}", user_id);
return Err(AuthError::InvalidCurrentPassword);
}

// 3. MFA gate. When the account has MFA, a code must be supplied
// and accepted by the same verifier used for login (TOTP or
// recovery code). Recovery codes are single-use; verify_mfa_code
// burns them on success — that's the desired audit trail.
if user.mfa_enabled {
let code = mfa_code
.filter(|c| !c.is_empty())
.ok_or(AuthError::MfaCodeRequired)?;
// Reuse the user_service verifier so behavior matches login
// exactly (TOTP skew window, recovery code burn-on-use).
let user_service = crate::user_service::UserService::new(self.db.clone());
let mfa_ok = user_service
.verify_mfa_code(user_id, code)
.await
.map_err(|e| {
error!("MFA verification error during password change: {}", e);
AuthError::InvalidMfaCode
})?;
if !mfa_ok {
return Err(AuthError::InvalidMfaCode);
}
}

// 4. Refuse no-op changes — silently succeeding here is confusing
// ("did it work?") and weakens the audit log.
if current_password == new_password {
return Err(AuthError::SamePassword);
}

// 5. New-password complexity check. The crate-level helper is the
// same one register / reset use, so the rules stay consistent.
crate::auth_service::validate_password_complexity(new_password)
.map_err(|e| AuthError::WeakPassword(e.to_string()))?;

// 6. Hash + persist. We also bump updated_at; downstream watchers
// (audit consumers, cache invalidators) key off this.
use argon2::password_hash::{rand_core::OsRng, SaltString};
let argon2 = argon2::Argon2::default();
let salt = SaltString::generate(&mut OsRng);
use argon2::PasswordHasher;
let new_hash = argon2
.hash_password(new_password.as_bytes(), &salt)
.map_err(|e| AuthError::EncryptionError(format!("password hash failed: {}", e)))?
.to_string();

let mut user_update: temps_entities::users::ActiveModel = user.clone().into();
user_update.password_hash = Set(Some(new_hash));
user_update.updated_at = Set(Utc::now());
let updated = user_update.update(self.db.as_ref()).await?;

// 7. Optional session sweep. Drops every session for this user
// EXCEPT the current one (kept so the user's tab keeps working).
// When current_session_token is None — e.g. called from a non-
// browser context — we drop everything to be safe.
if revoke_other_sessions {
use sea_orm::QueryFilter;
let mut q = temps_entities::sessions::Entity::delete_many()
.filter(temps_entities::sessions::Column::UserId.eq(user_id));
if let Some(tok) = current_session_token {
q = q.filter(temps_entities::sessions::Column::SessionToken.ne(tok));
}
if let Err(e) = q.exec(self.db.as_ref()).await {
// Don't fail the whole operation — the password is already
// rotated. Log loudly so an operator notices.
error!(
"Failed to revoke other sessions for user {} after password change: {}",
user_id, e
);
}
}

info!(
"Password changed for user {} (revoke_other_sessions={})",
user_id, revoke_other_sessions
);
Ok(updated)
}

// Verify email with token
pub async fn verify_email(
&self,
Expand Down
Loading
Loading