27
Basic crud with rust using tide - Adding OAuth
Hi there! welcome back, in the last post of this serie we made a final
refactor and completed the process of ci/cd
but I found a couple of bonus
to add that could be useful. In this first bonus post I will be add OAuth
to our app to allow users to login and manage their dinos
.
Until now we are not tracking the user that create a dino
, so everyone can edit or delete dinos
despite of if they created or not. Now we can introduce the user
concept and those dinos
created by an user can only be updated/deleted by that user, we will still supporting create dinos
without been logged and those will have the same behavior as they have now.
So, our roadmap for this note will be :
Implement OAuth, so the visitors can authenticate with a provider ( e.g google, github, etc )
Track the user that create the
dino
, if we have one.Add an authorization mechanism for the
update
anddelete
operations.
We will be using google
as OAuth provider, so before start we need to get your credentials ( here is the Setting up OAuth 2.0 documentation ).
Great, now that we had the credentials we can continue with the implementation. We will using this two examples as inspiration
for our implementation tide-tera-example and tide-google-oauth-example.
Also, @jmn was the one to give me the idea of adding this OAuth bonus to this serie, thanks π π!
Let's started, we need to add the deps
we will be using, first we will add oauth2
but we will try the last alpha
version and with reqwest
to use with async/await
.
oauth2 = { version = "4.0.0-alpha.3", features = ["reqwest"], default-features = false }
Move on and create the basic scaffolding, first we need to create the auth.rs
file inside the controllers
directory and put the basic skeleton
// controllers/auth.rs
use super::*;
use tide::{Request, Result};
pub async fn auth_google(req: Request<State>) -> Result {
unimplemented!();
}
pub async fn auth_google_authorized(req: Request<State>) -> Result {
unimplemented!();
}
pub async fn logout(req: Request<State>) -> Result {
unimplemented!();
}
So, we will be handle three routes:
/auth/google
will start the process and redirect the user to authorize our app./auth/google/authorized
will handle the callback from the provider ( google in this case ), get theuser info
and create thesession
./logout
will destroy the session.
Wait! session you say
... Yes, we will use cookie based sessions in this example, so we need to add the Session
middleware to our server.
// main.rs
async fn server(db_pool: PgPool) -> Server<State> {
(...)
app.with(tide::sessions::SessionMiddleware::new(
tide::sessions::MemoryStore::new(),
std::env::var("TIDE_SECRET")
.expect("Please provide a TIDE_SECRET value of at least 32 bytes")
.as_bytes(),
));
To keep things simple we will use the builtin MemoryStore
but it's recommended
to use one of the others stores in production
.
Great! let's add also the routes in our main.rs
// auth
app.at("/auth/google")
.get(auth::login)
.at( "/authorized").get(auth::login_authorized);
app.at("/logout").get(auth::logout);
Nice! so, now we had a bunch of
env vars
to add, remember to add those to our.env
file.
We had all the structure in place, now we need to actually implement the OAuth client, let's add a helper
function to create the basic client
fn make_oauth_google_client() -> tide::Result<BasicClient> {
let client = BasicClient::new(
ClientId::new(std::env::var("OAUTH_GOOGLE_CLIENT_ID").expect("missing env var OAUTH_GOOGLE_CLIENT_ID")),
Some(ClientSecret::new(std::env::var("OAUTH_GOOGLE_CLIENT_SECRET").expect("missing env var OAUTH_GOOGLE_CLIENT_SECRET"))),
AuthUrl::new(AUTH_URL.to_string())?,Some(TokenUrl::new(TOKEN_URL.to_string())?),
)
.set_redirect_url(RedirectUrl::new(std::env::var("OAUTH_GOOGLE_REDIRECT_URL").expect("missing env var OAUTH_GOOGLE_REDIRECT_URL"))?);
Ok(client)
}
and store in our State
#[derive(Clone, Debug)]
pub struct State {
db_pool: PgPool,
tera: Tera,
oauth_google_client: BasicClient
}
(...)
let oauth_google_client = make_oauth_google_client().unwrap();
let state = State { db_pool, tera, oauth_google_client };
let mut app = tide::with_state(state);
Nice! we can move on and work in the auth
logic.
First, we need to handle the get
request that start the OAuth flow in our auth_google
fn. We are getting the client
from the state and adding the scope
s we want to use. With the goal of keep the things simple we will only request the profile
since we will use the firstName
and the id
only.
The rest of the code have the
pub async fn auth_google(req: Request<State>) -> Result {
let client = &req.state().oauth_google_client;
let (auth_url, _csrf_token) = client
.authorize_url(CsrfToken::new_random)
// Set the desired scopes.
// .add_scope(Scope::new(AUTH_GOOGLE_SCOPE_EMAIL.to_string()))
.add_scope(Scope::new(AUTH_GOOGLE_SCOPE_PROFILE.to_string()))
.url();
Ok(Redirect::see_other(auth_url).into())
}
The last line redirect
the user to the google
page to ask the user if grant the request permissions/data to our app.
After accept the user will be redirected to our callback
fn, so now let's complete the flow to get the user info.
#[derive(Debug, Deserialize)]
struct AuthRequestQuery {
code: String,
state: String,
scope: String,
}
#[derive(Debug, Deserialize)]
struct UserInfoResponse {
// email: String,
id: String,
given_name: String
}
(...)
pub async fn auth_google_authorized(mut req: Request<State>) -> Result {
let client = &req.state().oauth_google_client;
let query: AuthRequestQuery = req.query()?;
let token_result = client
.exchange_code(AuthorizationCode::new(query.code))
.request_async(async_http_client)
.await;
let token_result = match token_result {
Ok(token) => token,
Err(_) => return Err(tide::Error::from_str(401, "error"))
};
let userinfo: UserInfoResponse = surf::get("https://www.googleapis.com/oauth2/v2/userinfo")
.header(
http::headers::AUTHORIZATION,
format!("Bearer {}", token_result.access_token().secret()),
)
.recv_json()
.await?;
let session = req.session_mut();
session.insert("user_name", userinfo.given_name)?;
session.insert("user_id", userinfo.id)?;
Ok(Redirect::new("/").into())
Again, we are first getting the client
from the store to exchange
the auth code that we extract from the query string to the AuthRequestQuery
struct.
Then, with the code we request the userInfo
and deserialize the response into the UserInfoResponse
struct. In the last lines we are storing the user_id
and user_name
into the session and redirect the logged in
user to the home again.
So, let's add the link to log in
and test this flow...
Whoops... the flow works but the browser is not sending the right cookie.
After checking this issue, I found this so answer that point me to check the SameSite
policy. And set the policy to Lax
make the flow works :-)
app.with(tide::sessions::SessionMiddleware::new(
tide::sessions::MemoryStore::new(),
std::env::var("TIDE_SECRET")
.expect("Please provide a TIDE_SECRET value of at least 32 bytes")
.as_bytes(),
).with_same_site_policy(SameSite::Lax)
);
Awesome!!! We just need to implement the logout
and we will finish the first item of our goals.
pub async fn logout(mut req: Request<State>) -> Result {
let session = req.session_mut();
session.destroy();
Ok(Redirect::new("/").into())
}
Nice, this will handle the logout
and redirect the user to home again.
The next item in our list is to track the user in the database, for this task we need another field in our db that could be null
or an string
that represente the user_id
.
After adding the field in our dinos
table, we need to update our Dino
struct.
[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Dino {
id: Uuid,
name: String,
weight: i32,
diet: String,
user_id: Option<String>
}
so, user_id
is an Option
that can hold an String
. Now we need to add an extra logic to add the user_id
in our controller
pub async fn create(mut req: Request<State>) -> tide::Result {
let mut dino: Dino = req.body_json().await?;
let db_pool = req.state().db_pool.clone();
let session = req.session();
match session.get("user_id") {
Some(id) => dino.user_id = Some(id),
None => dino.user_id = None
};
let row = handlers::dino::create(dino, &db_pool).await?;
let mut res = Response::new(201);
res.set_body(Body::from_json(&row)?);
Ok(res)
}
And update the handler
fns to use the new field
pub async fn create(dino: Dino, db_pool: &PgPool) -> tide::Result<Dino> {
let row: Dino = query_as!(
Dino,
r#"
INSERT INTO dinos (id, name, weight, diet, user_id) VALUES
($1, $2, $3, $4, $5) returning id as "id!", name, weight, diet, user_id
"#,
dino.id,
dino.name,
dino.weight,
dino.diet,
dino.user_id
)
.fetch_one(db_pool)
.await
.map_err(|e| Error::new(409, e))?;
Ok(row)
}
(...)
Now you can login, create a new
dino
and see that the new row has theuser_id
set. Also, if you create a newdino
without been logged in this column should benull
.
Nice! second goal done, we are tracking the id
of the users when they create a new dino
.
The last item in our list is restrict the update and delete operations. If a dino
was created by a logged in user only this user is authorized to delete
or edit
this dino
. We will add this auth
rule in two different places.
First in the ui
, the edit and delete options should only be available for public dinos
or the ones that the user created.
We can archive this with some logic in the tera
template
{% if dino.user_id %}
{%if user_id != "" and dino.user_id == user_id %}
<td><a href="/dinos/{{dino.id}}/edit"> Edit </a></td>
<td><a class="delete" data-id="{{dino.id}}"href="#"> Delete </a></td>
{% else %}
<td></td>
<td></td>
{% endif %}
{% else %}
<td><a href="/dinos/{{dino.id}}/edit"> Edit </a></td>
<td><a class="delete" data-id="{{dino.id}}"href="#"> Delete </a></td>
{% endif %}
Nice!! we are half way, we just restrict the ui
but we also need to check this restriction in the controller.
//controllers/dino.rs
(...)
// auth operation
let session = req.session();
let user_id: String = session.get("user_id").unwrap_or("".to_string());
let row = handlers::dino::get(id, &db_pool).await?;
if let Some(dino) = row {
if dino.user_id.is_some() && dino.user_id.unwrap() != user_id {
// 401
return Ok(Response::new(401));
}
}
(...)
We are checking that the user_id
( if is set ) is the same of the session, and if not we just return a
.
401 Unauthorized
Nice! we have to add some tests now to check if this works as expected
#[async_std::test]
async fn updatet_dino_create_by_another_user_should_reject_with_401() -> tide::Result<()> {
dotenv::dotenv().ok();
let mut dino = Dino {
id: Uuid::new_v4(),
name: String::from("test_update"),
weight: 500,
diet: String::from("carnivorous"),
user_id: Some(String::from("123"))
};
let db_pool = make_db_pool(&DB_URL).await;
// create the dino for update
query!(
r#"
INSERT INTO dinos (id, name, weight, diet, user_id) VALUES
($1, $2, $3, $4, $5) returning id
"#,
dino.id,
dino.name,
dino.weight,
dino.diet,
dino.user_id
)
.fetch_one(&db_pool)
.await?;
// change the dino
dino.name = String::from("updated from test");
// start the server
let app = server(db_pool).await;
let res = surf::Client::with_http_client(app)
.put(format!("https://example.com/dinos/{}", &dino.id))
.body(serde_json::to_string(&dino)?)
.await?;
assert_eq!(401, res.status());
Ok(())
}
#[async_std::test]
async fn delete_dino_create_by_another_user_should_reject_with_401() -> tide::Result<()> {
dotenv::dotenv().ok();
let dino = Dino {
id: Uuid::new_v4(),
name: String::from("test_delete"),
weight: 500,
diet: String::from("carnivorous"),
user_id: Some(String::from("123"))
};
let db_pool = make_db_pool(&DB_URL).await;
// create the dino for delete
query!(
r#"
INSERT INTO dinos (id, name, weight, diet, user_id) VALUES
($1, $2, $3, $4, $5) returning id
"#,
dino.id,
dino.name,
dino.weight,
dino.diet,
dino.user_id
)
.fetch_one(&db_pool)
.await?;
// start the server
let app = server(db_pool).await;
let res = surf::Client::with_http_client(app)
.delete(format!("https://example.com/dinos/{}", &dino.id))
.await?;
assert_eq!(401, res.status());
Ok(())
}
Nice, now we can run those test :)
cargo test
Finished test [unoptimized + debuginfo] target(s) in 0.31s
Running target/debug/deps/tide_basic_crud-b16dbe7e8764d4d3
running 12 tests
test tests::clear ... ok
test tests::create_dino_with_existing_key ... ok
test tests::delete_dino_create_by_another_user_should_reject_with_401 ... ok
test tests::delete_dino ... ok
test tests::create_dino ... ok
test tests::get_dino_non_existing_key ... ok
test tests::get_dino ... ok
test tests::delete_dino_non_existing_key ... ok
test tests::list_dinos ... ok
test tests::updatet_dino_create_by_another_user_should_reject_with_401 ... ok
test tests::update_dino ... ok
test tests::updatet_dino_non_existing_key ... ok
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Awesome!! we just finished the third item in our list.
That's all for today... first bonus of this serie, we just implemented OAuth ( with google as provider ) and now users can track his dinos
:-), you can check the complete code in the PR.
I think there are still room to improve ( like adding a mechanism to authorize api calls ) but we can leave for the next bonus post
.
As always, I write this as a learning journal and there could be another more elegant and correct way to do it and any feedback is welcome.
Thanks!
27