36
loading...
This website collects cookies to deliver better user experience
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
.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.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
and delete
operations.
google
as OAuth provider, so before start we need to get your credentials ( here is the Setting up OAuth 2.0 documentation ).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 🙌 🙌!
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 }
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!();
}
/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 the user info
and create the session
.
/logout
will destroy the session.
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(),
));
MemoryStore
but it's recommended
to use one of the others stores in production
.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.
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)
}
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);
auth
logic.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 email
commented in case you also want to use.
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())
}
redirect
the user to the google
page to ask the user if grant the request permissions/data to our app.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())
client
from the store to exchange
the auth code that we extract from the query string to the AuthRequestQuery
struct.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.log in
and test this flow...Whoops... the flow works but the browser is not sending the right cookie.
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)
);
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())
}
logout
and redirect the user to home again.null
or an string
that represente the user_id
.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>
}
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)
}
handler
fns to use the new fieldpub 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 the user_id
set. Also, if you create a new dino
without been logged in this column should be null
.
id
of the users when they create a new dino
.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.ui
, the edit and delete options should only be available for public dinos
or the ones that the user created.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 %}
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));
}
}
(...)
user_id
( if is set ) is the same of the session, and if not we just return a
401 Unauthorized
.#[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(())
}
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
dinos
:-), you can check the complete code in the PR.bonus post
.