Building a Login Manager App

We've already built a moderately useful login system: it can read users from a JSON file, creating a default if necessary. Logins are checked, passwords are hashed, and different login roles work. Let's spend the rest of our time together building a login_manager application that provides a command-line interface to our login system.

Creating a New Project

Create a new login_manager project:

cargo new login_manager

Open the parent Cargo.toml and add login_manager to the workspace.

Now add the auth library to your login_manager's Cargo.toml file:

[dependencies] auth = { path = "../auth" }

Creating a CLI

The de-facto standard approach to building CLI applications is provided by a crate named clap. Add it with:

cargo add clap -F derive

Clap does a lot, and the "derive" feature adds some useful macros to reduce the amount of typing we need to do.

Let's create a minimal example and have a look at what Clap is doing for us:

use clap::{Parser, Subcommand}; #[derive(Parser)] #[command()] struct Args { #[command(subcommand)] command: Option<Commands>, } #[derive(Subcommand)] enum Commands { /// List all users. List, } fn main() { let cli = Args::parse(); match cli.command { Some(Commands::List) => { println!("All Users Goes Here\n"); } None => { println!("Run with --help to see instructions"); std::process::exit(0); } } }

This has added a surprising amount of functionality!

cargo run on its own emits Run with --help to see instructions. Clap has added --help for us.

Running cargo and then passing command-line arguments through uses some slightly strange syntax. Let's give --help a go:

cargo run -- --help Usage: login_manager.exe [COMMAND] Commands: list List all users help Print this message or the help of the given subcommand(s) Options: -h, --help Print help

You an even ask it for help about the list feature:

List all users Usage: login_manager.exe list Options: -h, --help Print help

Now, let's implement the list command.

fn list_users() { println!("{:<20}{:<20}", "Username", "Login Action"); println!("{:-<40}", ""); let users = get_users(); users .iter() .for_each(|(_, user)| { println!("{:<20}{:<20?}", user.username, user.role); }); } fn main() { let cli = Args::parse(); match cli.command { Some(Commands::List) => list_users(), None => { println!("Run with --help to see instructions"); std::process::exit(0); } } }

Now running cargo run -- list gives us:

Username Login Action ---------------------------------------- admin Admin bob User

Adding Users

We're going to need a way to save the users list, so in the auth library let's add a function:

#![allow(unused)] fn main() { pub fn save_users(users: &HashMap<String, User>) { let users_path = Path::new("users.json"); let users_json = serde_json::to_string(&users).unwrap(); std::fs::write(users_path, users_json).unwrap(); } }

This is the same as what we did before---but exposed as a function.

Let's add an "add" option. It will have parameters, you need to provide a username, password and indicate if the user is an administrator:

#![allow(unused)] fn main() { #[derive(Subcommand)] enum Commands { /// List all users. List, /// Add a user. Add { /// Username username: String, /// Password password: String, /// Optional - mark as an admin #[arg(long)] admin: Option<bool>, } } }

Add a dummy entry to the match statement:

#![allow(unused)] fn main() { Some(Commands::Add { username, password, admin }) => {}, }

And run cargo run -- add --help to see what Clap has done for us:

Add a user Usage: login_manager.exe add [OPTIONS] <USERNAME> <PASSWORD> Arguments: <USERNAME> Username <PASSWORD> Password Options: --admin <ADMIN> Optional - mark as an admin [possible values: true, false] -h, --help Print help

Now we can implement the add command:

fn add_user(username: String, password: String, admin: bool) { let mut users = get_users(); let role = if admin { LoginRole::Admin } else { LoginRole::User }; let user = User::new(&username, &password, role); users.insert(username, user); save_users(&users); } fn main() { let cli = Args::parse(); match cli.command { Some(Commands::List) => list_users(), Some(Commands::Add { username, password, admin }) => add_user(username, password, admin.is_some()), None => { println!("Run with --help to see instructions"); std::process::exit(0); } } }

And now you can run cargo run -- add fred password and see the new user in the list.

{ "fred": { "username": "fred", "password": "5E884898DA28047151D0E56F8DC6292773603D0D6AABBDD62A11EF721D1542D8", "role": "User" }, "admin": { "username": "admin", "password": "5E884898DA28047151D0E56F8DC6292773603D0D6AABBDD62A11EF721D1542D8", "role": "Admin" }, "bob": { "username": "bob", "password": "5E884898DA28047151D0E56F8DC6292773603D0D6AABBDD62A11EF721D1542D8", "role": "User" } }

Let's add one more thing. Warn the user if a duplicate occurs:

#![allow(unused)] fn main() { fn add_user(username: String, password: String, admin: bool) { let mut users = get_users(); if users.contains_key(&username) { println!("{username} already exists"); return; } }

Deleting Users

Let's add a delete command. This will take a username and remove it from the list:

#![allow(unused)] fn main() { #[derive(Subcommand)] enum Commands { /// List all users. List, /// Add a user. Add { /// Username username: String, /// Password password: String, /// Optional - mark as an admin #[arg(long)] admin: Option<bool>, }, /// Delete a user Delete { /// Username username: String, }, } }

As expected, --help and cargo run -- delete --help` have been updated.

Now let's implement the deletion:

#![allow(unused)] fn main() { fn delete_user(username: &str) { let mut users = get_users(); if users.contains_key(username) { users.remove(username); save_users(&users); } else { println!("{username} does not exist"); } } }

And add it to the command matcher:

#![allow(unused)] fn main() { Some(Commands::Delete { username }) => delete_user(&username), }

You can now remove fred from the list with cargo run -- delete fred. Check that he's gone with cargo run -- list:

Username Login Action ---------------------------------------- bob User admin Admin

Changing Passwords

You've got the Create, Read and Delete of "CRUD" - let's add some updating!

A command to change the user's password is in order. This will take a username and a new password:

#![allow(unused)] fn main() { enum Commands { /// List all users. List, /// Add a user. Add { /// Username username: String, /// Password password: String, /// Optional - mark as an admin #[arg(long)] admin: Option<bool>, }, /// Delete a user Delete { /// Username username: String, }, /// Change a password ChangePassword { /// Username username: String, /// New Password new_password: String, }, } }

And let's implement it:

#![allow(unused)] fn main() { fn change_password(username: &str, password: &str) { let mut users = get_users(); if let Some(user) = users.get_mut(username) { user.password = auth_login_manager::hash_password(password); save_users(&users); } else { println!("{username} does not exist"); } } }

And add it to the match:

#![allow(unused)] fn main() { Some(Commands::ChangePassword { username, new_password }) => { change_password(&username, &new_password) } }

Go ahead and test changing a password.