Welcome back! We're making some headway into our app, we finally have the ability to register new users, let's continue and add in the ability to log in!
Before we dive into the programming, let's go over what logging in really means. On our hackerclone website we only want registered users to submit new posts and comment. To do this we need to make sure that those requests are coming from registered users. One way we can do this on each request we can have the user input their username and password. We would then check that against our database and allow them to either post or not.
This is however troublesome for the user, they would need to verify themselves on each request. The next thing we can do is automate that via cookies. Cookies are bits of information the browser will send back to the website each time. Our website could save the username and password the user enters at the beginning in a cookie and then from that point onwards the browser will send the cookies with each request.
This is bad form, storing usernames and passwords would make it so that if the cookies somehow got read by someone on its way to our server then that user is now compromised.
This leads us to the idea of sessions. When a user logs in the first time and they verify themselves, we will create a random string and keep it in a table in memory. We then set the user's cookie to contain this string. Now every request that the user makes will give us this random string, and we can then look in our table to see who is matched to that string. This is still susceptible to being captured by a hacker but the hacker would only have access to that session. They wouldn't get the user's real credentials. We can also add time outs for sessions to add a little bit more security.
This is the method we will be using in our own login function. We'll have a user log in, make sure the passwords match, create a session for that user and add it to our session table. Logging out would then be simple, all we need to do is remove the user from the session table.
Let's get started!
Logging in a User
The first thing we'll do is move our LoginUser struct that we created a few chapters ago to our models file and include it via the use statement. This will just be a little tidying up.
./src/models.rs
...
#[derive(Debug, Deserialize)]
pub struct LoginUser {
pub username: String,
pub password: String,
}
...
./src/main.rs
...
use models::{User, NewUser, LoginUser};
...
We will slowly move all our structs out of our main.rs file because it makes more sense for them to all be centralized in our models.rs file.
Now let's work on verifying the user who is trying to login. What we need to do is go to the database and find the username that is trying to login, if that user exists we need to check their password. If and only if the passwords match do we want to create a session.
./src/main.rs
...
async fn process_login(data: web::Form<LoginUser>) -> impl Responder {
use schema::users::dsl::{username, users};
let connection = establish_connection();
let user = users.filter(username.eq(&data.username)).first::<User>(&connection);
match user {
Ok(u) => {
if u.password == data.password {
println!("{:?}", data);
HttpResponse::Ok().body(format!("Logged in: {}", data.username))
} else {
HttpResponse::Ok().body("Password is incorrect.")
}
},
Err(e) => {
println!("{:?}", e);
HttpResponse::Ok().body("User doesn't exist.")
}
}
}
...
The first thing to notice here is that like before we are going to include some code from our schema so we can use our user table.
The next step is to create our connection to postgres. Right now we are creating a new connection on each request which can get expensive. In the future we will set up a pool of connections that we can then just use.
The next step is to check our database for our user. We do a filter and because we put a UNIQUE constraint on our field when making our sql file, we can grab just the first result we get. We pass in the connection to first which will execute our filter.
Now we will get a result type that we can match against. If the result was Ok then we can go into the next set of logic. If it was an Err, we will print a very helpful message. (Not a good idea for production)
If the result was Ok, then we can check the password, and if they match we will print our original login message to our terminal. If the passwords don't match, we'll let the user know.
Now if we navigation to our browser and go to 127.0.0.1:8000/login we should be able to login in with the user we already created.
We should see the below in our terminal.
LoginUser { username: "niv", password: "test" }
Done!
Joking! Not yet, we will now add our session table and make it so if a user logs in, they will then get a cookie set with the session information.
Adding Sessions
To add sessions to our rust application we are going to include another crate. First we'll add it to our dependencies.
./Cargo.toml
...
diesel = { version = "1.4.4", features = ["postgres"] }
actix-identity = "0.3.1"
actix-identity is a crate that allows us to maintain sessions using cookies.
The next step is to include the relevant parts of actix-identity into our project.
./src/main.rs
...
use serde::{Serialize, Deserialize};
use actix_identity::{Identity, CookieIdentityPolicy, IdentityService};
...
We will be using these 4 objects from the actix_identity crate.
Now we need to let our rust application know that we are going to be using sessions. What we want to do is make sure that any requests coming in have a cookie set on them and that we are always sending that information to the user and the browser on the other side will make sure we get that information back.
To do this we will need to register the IdentityService in our app, similar to how we did with tera. With tera, our templating engine, we wanted to make a variable accessible to functions we call within our App. With our IdentityService we want it for our requests. so instead of using .data() to register it, we will use .wrap().
./src/main.rs
...
App::new()
.wrap(IdentityService::new(
CookieIdentityPolicy::new(&[0;32])
.name("auth-cookie")
.secure(false)
)
)
.data(tera)
.route("/", web::get().to(index))
...
Here we register our IdentityService with the wrap option, on our App object which sits inside our HttpServer. We will now have the ability to create sessions.
Let's go back to our login function.
./src/main.rs
async fn process_login(data: web::Form<LoginUser>, id: Identity) -> impl Responder {
use schema::users::dsl::{username, users};
let connection = establish_connection();
let user = users.filter(username.eq(&data.username)).first::<User>(&connection);
match user {
Ok(u) => {
if u.password == data.password {
let session_token = String::from(u.username);
id.remember(session_token);
HttpResponse::Ok().body(format!("Logged in: {}", data.username))
} else {
HttpResponse::Ok().body("Password is incorrect.")
}
},
Err(e) => {
println!("{:?}", e);
HttpResponse::Ok().body("User doesn't exist.")
}
}
}
We've made only 3 changes here, we added id as a parameter for this function, this is something that we can now pass in just like we did with tera.
Then inside our password check, if it passes, we will create our session token and add it to our session table. This also sets the user's cookie with that information. With that we now have sessions!
What actix_identity's id option is doing is it is taking our value and it creates a hash out of it that it keeps in it's own table.
Earlier we wrapped an IdentityService around our application, this means that when a request comes in, it grabs the "auth-cookie" and does a look up to see what the corresponding id should be. This is is the value we set inside the .remember().
Let's make sure everything is working by making it so when a user goes to the login page, we'll check to see if they're already logged in. If they are logged in we'll display a message, whereas if they aren't logged in, it will be the regular page.
For this we will update our login function.
./src/main.rs
async fn login(tera: web::Data<Tera>, id: Identity) -> impl Responder {
let mut data = Context::new();
data.insert("title", "Login");
if let Some(id) = id.identity() {
return HttpResponse::Ok().body("Already logged in.")
}
let rendered = tera.render("login.html", &data).unwrap();
HttpResponse::Ok().body(rendered)
}
We once again add id to our parameter list that we need to pass in. The "if let Some(id)" lets quickly check the id.identity() function. This function checks to see if the session token we saved in the cookie exists in our session table. If it does, the check will pass and we don't need to display the login page. If it doesn't exist, then we should allow the user to log in.
Let's do one more thing before we finish up. Let's wire together a logout! That way we can actually remove things from our session table.
The first thing to do is add a route for logout.
./src/main.rs
...
.route("/login", web::post().to(process_login))
.route("/logout", web::to(logout))
...
Then the next thing would be to write our logout function.
./src/main.rs
async fn logout(id: Identity) -> impl Responder {
id.forget();
HttpResponse::Ok().body("Logged out.")
}
id.forget() removes the session token we set from the session table and our cookie. This is what logging out really means on a website!
To see how this works, you can log into the site and then open the developer console and look under the storage tab. Here we should see that we have a cookie with a random string in it. Inside actix_identity we have a table of these random strings matched to our real values, so when we call id in our rust function, we will be able to get what we need!
And with that we are done! Well done, we have finished up another major piece of our puzzle. We now have users who can sign up, we can log them in and we can log them out. We can handle sessions so users can now make posts and comment.
In the next chapter we'll build our submission page!
See you soon.
Top comments (0)