Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/spec-forest-app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
db_path: cli.db_path,
sync_url: cli.sync_url,
log_prompts: cli.log_prompts,
user_name: None,
};

let (app, ct, _state) = spec_forest::build_server(config).await?;
Expand Down
30 changes: 30 additions & 0 deletions crates/spec-forest-protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ pub enum ClientMessage {
Refresh {
token: String,
},
ChangePassword {
current_password: String,
new_password: String,
},
GrantAccess {
spec_name: String,
username: String,
Expand Down Expand Up @@ -275,6 +279,7 @@ pub enum ServerMessage {
AuthToken {
token: String,
},
PasswordChanged,
TokenExpiring {
remaining_secs: u64,
},
Expand Down Expand Up @@ -594,4 +599,29 @@ mod tests {
_ => panic!("wrong variant"),
}
}

#[test]
fn test_change_password_roundtrip() {
let msg = ClientMessage::ChangePassword {
current_password: "old123".into(),
new_password: "new456".into(),
};
let json = serde_json::to_string(&msg).unwrap();
let decoded: ClientMessage = serde_json::from_str(&json).unwrap();
match decoded {
ClientMessage::ChangePassword { current_password, new_password } => {
assert_eq!(current_password, "old123");
assert_eq!(new_password, "new456");
}
_ => panic!("wrong variant"),
}
}

#[test]
fn test_password_changed_roundtrip() {
let msg = ServerMessage::PasswordChanged;
let json = serde_json::to_string(&msg).unwrap();
let decoded: ServerMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, ServerMessage::PasswordChanged));
}
}
60 changes: 60 additions & 0 deletions crates/spec-forest-sync/src/server/handlers/auth_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,66 @@ async fn login_user(
Ok((token, claims))
}

pub async fn handle_change_password(
ctx: &mut HandlerContext<'_>,
current_password: String,
new_password: String,
) -> Result<AuthTransition, SyncError> {
let username = ctx.require_auth()?.to_string();

if new_password.len() < 8 {
return Err(SyncError::Auth("New password must be at least 8 characters".into()));
}

let state = Arc::clone(ctx.state);
let db = state.db.lock().await;

if db.is_account_locked(&username)? {
return Err(SyncError::Auth(
"Account is temporarily locked due to too many failed attempts".into(),
));
}

let user = db
.get_user(&username)?
.ok_or_else(|| SyncError::Auth("User not found".into()))?;

let valid = crate::auth::verify_password(&user.password_hash, &current_password)
.map_err(|e| SyncError::PasswordHash(format!("{e}")))?;

if !valid {
if let Err(e) = db.record_login_failure(&username) {
tracing::error!(username = %username, "Failed to record login failure: {e}");
}
return Err(SyncError::Auth("Current password is incorrect".into()));
}

if let Err(e) = db.reset_login_failures(&username) {
tracing::error!(username = %username, "Failed to reset login failures: {e}");
}

let new_hash = crate::auth::hash_password(&new_password)
.map_err(|e| SyncError::PasswordHash(format!("{e}")))?;

db.update_password(&username, &new_hash)
.map_err(|e| SyncError::Auth(format!("Failed to update password: {e}")))?;
drop(db);

try_send_msg(ctx.ws, &ServerMessage::PasswordChanged).await?;

// Issue a fresh token since the old one is now stale (password_changed_at updated)
let token = ctx.auth_config.create_token(&username)?;
let claims = ctx.auth_config.validate_token(&token)?;
try_send_msg(ctx.ws, &ServerMessage::AuthToken { token }).await?;

let token_exp = claims.exp;
*ctx.session = Session::Authenticated {
username: username.clone(),
token_exp,
};
Ok(AuthTransition::Refreshed { token_exp })
}

async fn refresh_token(
state: &Arc<SyncState>,
auth_config: &AuthConfig,
Expand Down
7 changes: 7 additions & 0 deletions crates/spec-forest-sync/src/server/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ pub async fn dispatch(ctx: &mut HandlerContext<'_>, msg: ClientMessage) -> AuthT
ClientMessage::Refresh { token } => {
(auth_handlers::handle_refresh(ctx, token).await, None)
}
ClientMessage::ChangePassword {
current_password,
new_password,
} => (
auth_handlers::handle_change_password(ctx, current_password, new_password).await,
None,
),
ClientMessage::Subscribe {
spec_name,
from_seq,
Expand Down
18 changes: 18 additions & 0 deletions crates/spec-forest/src/api/sync_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,21 @@ pub async fn sync_connect(

Ok(())
}

pub async fn sync_change_password(
state: &Arc<AppState>,
current_password: &str,
new_password: &str,
) -> Result<(), ApiError> {
let handle = state
.get_sync_handle()
.await
.ok_or_else(|| ApiError::Internal("Not connected to sync server".into()))?;
handle
.change_password(current_password, new_password)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
// Update the in-memory cached password
state.set_sync_password(new_password.to_string());
Ok(())
}
15 changes: 15 additions & 0 deletions crates/spec-forest/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ pub fn router(state: SharedState) -> Router {
.route("/api/sync/register", post(sync_register))
.route("/api/sync/login", post(sync_login))
.route("/api/sync/connect", post(sync_connect))
.route("/api/sync/change-password", post(sync_change_password))
.route("/api/sync/grant-access", post(sync_grant_access))
.route("/api/sync/revoke-access", post(sync_revoke_access))
.route("/api/sync/members", get(sync_list_members))
Expand Down Expand Up @@ -722,6 +723,20 @@ async fn sync_connect(
Ok(Json(serde_json::json!({"status": "connected"})))
}

async fn sync_change_password(
State(state): State<SharedState>,
axum::Json(body): axum::Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, AppError> {
let current = body["current_password"]
.as_str()
.ok_or_else(|| AppError::Internal("current_password required".into()))?;
let new_pw = body["new_password"]
.as_str()
.ok_or_else(|| AppError::Internal("new_password required".into()))?;
crate::api::sync_change_password(&state, current, new_pw).await?;
Ok(Json(serde_json::json!({"status": "password_changed"})))
}

async fn server_status(
State(state): State<SharedState>,
) -> Json<serde_json::Value> {
Expand Down
8 changes: 6 additions & 2 deletions crates/spec-forest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub struct ServerConfig {
pub db_path: String,
pub sync_url: Option<String>,
pub log_prompts: Option<String>,
pub user_name: Option<String>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This field is always set to None in both main.rs files — there's no CLI flag wired up to populate it, so the priority logic added below (config.user_name > DB setting) is dead code from the user's perspective.

I'd suggest removing user_name from ServerConfig entirely for now rather than shipping an unused field. The DB-based persistence already works, and the username is editable via the new Account panel. If we need a CLI override later we can add it then with the actual --user-name flag.

}

/// Creates an initialized AppState with the op_loop running, but without
Expand All @@ -46,8 +47,11 @@ pub async fn build_app_state(
eprintln!("Logging AI prompts to {log_path}");
}

// Load persisted username from the database
if let Ok(Some(user_name)) = state.db().get_setting("user_name") {
// CLI flag takes priority, then persisted DB setting
if let Some(ref name) = config.user_name {
state.set_user_name(name.clone());
let _ = state.db().set_setting("user_name", name);
} else if let Ok(Some(user_name)) = state.db().get_setting("user_name") {
state.set_user_name(user_name);
}

Expand Down
1 change: 1 addition & 0 deletions crates/spec-forest/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
db_path: cli.db_path,
sync_url: cli.sync_url,
log_prompts: cli.log_prompts,
user_name: None,
};

let (app, ct, _state) = spec_forest::build_server(config).await?;
Expand Down
4 changes: 4 additions & 0 deletions crates/spec-forest/src/sync/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ impl PendingRequests {
self.dispatch_member_list(members, creator, client_ref).await;
None
}
ServerMessage::PasswordChanged => {
// Acknowledgment only — the subsequent AuthToken resolves the pending auth oneshot
None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the sync server crashes or the connection drops between sending PasswordChanged and the subsequent AuthToken, the auth oneshot in change_password() will hang indefinitely (or until the WebSocket close is detected and tears things down).

Since PasswordChanged is purely informational and nothing reads it, consider either:

  1. Removing the PasswordChanged message entirely and just relying on the AuthToken response to signal success, or
  2. Having PasswordChanged itself resolve the pending auth oneshot (and treat AuthToken as a bonus refresh), so the client isn't dependent on two messages arriving in sequence.

}
ServerMessage::TokenExpiring { remaining_secs } => {
eprintln!("sync: token expiring in {remaining_secs}s");
None
Expand Down
29 changes: 29 additions & 0 deletions crates/spec-forest/src/sync/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,35 @@ impl SyncHandle {
}
}

pub async fn change_password(
&self,
current_password: &str,
new_password: &str,
) -> Result<(), SyncError> {
let (tx, rx) = oneshot::channel();
self.inner.pending.set_auth(tx).await;

let msg = ClientMessage::ChangePassword {
current_password: current_password.to_string(),
new_password: new_password.to_string(),
};
let result = request::send_and_wait_singleton(
&*self.inner.connection.lock().await,
&msg,
rx,
"change_password",
)
.await?;

match result {
Ok(token) => {
*self.inner.token.lock().await = Some(token);
Ok(())
}
Err(e) => Err(SyncError::AuthFailed(e)),
}
}

// --- Subscriptions ---

pub async fn subscribe(
Expand Down
2 changes: 2 additions & 0 deletions crates/spec-forest/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ impl TestServer {
db_path,
sync_url: None,
log_prompts: None,
user_name: None,
};

let (router, ct, _state) = spec_forest::build_server(config).await.unwrap();
Expand Down Expand Up @@ -219,6 +220,7 @@ impl TestServer {
db_path,
sync_url: Some(sync_url.to_string()),
log_prompts: None,
user_name: None,
};

let (router, ct, _state) = spec_forest::build_server(config).await.unwrap();
Expand Down
Loading