Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add API test for /login endpoint #25

Merged
merged 11 commits into from
Nov 25, 2023
Merged
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
6 changes: 6 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Changelog
All notable changes to this project will be documented in this file.

## [1.4.0] - Unreleased
## Changed
- Added integration tests for the backend. This change ensures that the backend is tested in a more realistic environment, providing more confidence in the application's functionality.

(PR [#28](https://github.com/security-union/yew-actix-template/pull/25))

## [1.3.0] - 2023-11-24
### Changed
- Added versioning and changelog.
Expand Down
12 changes: 8 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
test:
make test-api
make test-ui
test-api:
docker compose -f docker/docker-compose.yaml run actix-api bash -c "cd app/actix-api && cargo test -- --nocapture"
test-ui:
docker compose -f docker/docker-compose.yaml run yew-ui bash -c "cd app/yew-ui && cargo test"
docker compose -f docker/docker-compose.yaml run actix-api bash -c "cd app/actix-api && cargo test"

up:
docker compose -f docker/docker-compose.yaml up
down:
Expand All @@ -21,7 +24,8 @@ clippy-fix:
docker compose -f docker/docker-compose.yaml run yew-ui bash -c "cd app/yew-ui && cargo clippy --fix"
docker compose -f docker/docker-compose.yaml run actix-api bash -c "cd app/actix-api && cargo clippy --fix && cd ../types && cargo clippy --fix"

check:
check:
# The ui does not support clippy yet
#docker compose -f docker/docker-compose.yaml run yew-ui bash -c "cd app/yew-ui && cargo clippy --all -- --deny warnings && cargo fmt --check"
docker compose -f docker/docker-compose.yaml run actix-api bash -c "cd app/actix-api && cargo clippy --all -- --deny warnings && cargo fmt --check"
docker compose -f docker/docker-compose.yaml run actix-api bash -c "cd app/actix-api && cargo clippy --all -- --deny warnings && cargo fmt --check"

2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.3.0
1.4.0
4 changes: 2 additions & 2 deletions actix-api/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion actix-api/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "actix-api"
version = "1.3.0"
version = "1.4.0"
edition = "2021"
repository = "https://github.com/security-union/yew-actix-template.git"
description = "Actix-web backend"
Expand Down
180 changes: 180 additions & 0 deletions actix-api/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
use actix_cors::Cors;
use actix_web::{
body::{BoxBody, EitherBody},
cookie::{
time::{Duration, OffsetDateTime},
Cookie, SameSite,
},
dev::{ServiceFactory, ServiceRequest, ServiceResponse},
error, get, http,
web::{self, Json},
App, Error, HttpResponse,
};

use crate::auth::{
fetch_oauth_request, generate_and_store_oauth_request, request_token, upsert_user,
};
use crate::{
auth::AuthRequest,
db::{get_pool, PostgresPool},
};
use reqwest::header::LOCATION;
use types::HelloResponse;

const OAUTH_CLIENT_ID: &str = std::env!("OAUTH_CLIENT_ID");
const OAUTH_AUTH_URL: &str = std::env!("OAUTH_AUTH_URL");
const OAUTH_TOKEN_URL: &str = std::env!("OAUTH_TOKEN_URL");
const OAUTH_SECRET: &str = std::env!("OAUTH_CLIENT_SECRET");
const OAUTH_REDIRECT_URL: &str = std::env!("OAUTH_REDIRECT_URL");
const SCOPE: &str = "email%20profile%20openid";
pub const ACTIX_PORT: &str = std::env!("ACTIX_PORT");
const UI_PORT: &str = std::env!("TRUNK_SERVE_PORT");
const UI_HOST: &str = std::env!("TRUNK_SERVE_HOST");
const AFTER_LOGIN_URL: &str = concat!("http://localhost:", std::env!("TRUNK_SERVE_PORT"));

pub mod auth;
pub mod db;

/**
* Function used by the Web Application to initiate OAuth.
*
* The server responds with the OAuth login URL.
*
* The server implements PKCE (Proof Key for Code Exchange) to protect itself and the users.
*/
#[get("/login")]
async fn login(pool: web::Data<PostgresPool>) -> Result<HttpResponse, Error> {
// TODO: verify if user exists in the db by looking at the session cookie, (if the client provides one.)
let pool2 = pool.clone();

// 2. Generate and Store OAuth Request.
let (csrf_token, pkce_challenge) = {
let pool = pool2.clone();
generate_and_store_oauth_request(pool).await
}
.map_err(|e| {
log::error!("{:?}", e);
error::ErrorInternalServerError(e)
})?;

// 3. Craft OAuth Login URL
let oauth_login_url = format!("{oauth_url}?client_id={client_id}&redirect_uri={redirect_url}&response_type=code&scope={scope}&prompt=select_account&pkce_challenge={pkce_challenge}&state={state}&access_type=offline",
oauth_url=OAUTH_AUTH_URL,
redirect_url=OAUTH_REDIRECT_URL,
client_id=OAUTH_CLIENT_ID,
scope=SCOPE,
pkce_challenge=pkce_challenge.as_str(),
state=&csrf_token.secret()
);

// 4. Redirect the browser to the OAuth Login URL.
let mut response = HttpResponse::Found();
response.append_header((LOCATION, oauth_login_url));
Ok(response.finish())
}

/**
* Handle OAuth callback from Web App.
*
* This service is responsible for using the provided authentication code to fetch
* the OAuth access_token and refresh token.
*
* It upserts the user using their email and stores the access_token & refresh_code.
*/
#[get("/login/callback")]
async fn handle_google_oauth_callback(
pool: web::Data<PostgresPool>,
info: web::Query<AuthRequest>,
) -> Result<HttpResponse, Error> {
let state = info.state.clone();

// 1. Fetch OAuth request, if this fails, probably a hacker is trying to p*wn us.
let oauth_request = {
let pool = pool.clone();
fetch_oauth_request(pool, state).await
}
.map_err(|e| {
log::error!("{:?}", e);
error::ErrorBadRequest("couldn't find a request, are you a hacker?")
})?;

// 2. Request token from OAuth provider.
let (oauth_response, claims) = request_token(
OAUTH_REDIRECT_URL,
OAUTH_CLIENT_ID,
OAUTH_SECRET,
&oauth_request.pkce_verifier,
OAUTH_TOKEN_URL,
&info.code,
)
.await
.map_err(|err| {
log::error!("{:?}", err);
error::ErrorBadRequest("couldn't find a request, are you a hacker?")
})?;

// 3. Store tokens and create user.
{
let claims = claims.clone();
upsert_user(pool, &claims, &oauth_response).await
}
.map_err(|err| {
log::error!("{:?}", err);
error::ErrorInternalServerError(err)
})?;

// 4. Create session cookie with email.
let cookie = Cookie::build("email", claims.email)
.path("/")
.same_site(SameSite::Lax)
// Session lasts only 360 secs to test cookie expiration.
.expires(OffsetDateTime::now_utc().checked_add(Duration::seconds(360)))
.finish();

// 5. Send cookie and redirect browser to AFTER_LOGIN_URL
let mut response = HttpResponse::Found();
response.append_header((LOCATION, AFTER_LOGIN_URL));
response.cookie(cookie);
Ok(response.finish())
}

/**
* Sample service
*/
#[get("/hello/{name}")]
async fn greet(name: web::Path<String>) -> Json<HelloResponse> {
Json(HelloResponse {
name: name.to_string(),
})
}

pub fn get_app() -> App<
impl ServiceFactory<
ServiceRequest,
Config = (),
Response = ServiceResponse<EitherBody<BoxBody>>,
Error = actix_web::Error,
InitError = (),
>,
> {
// TODO: Deal with https, maybe we should just expose this as an env var?
darioalessandro marked this conversation as resolved.
Show resolved Hide resolved
let allowed_origin = if UI_PORT != "80" {
format!("http://{}:{}", UI_HOST, UI_PORT)
} else {
format!("http://{}", UI_HOST)
};
let cors = Cors::default()
.allowed_origin(allowed_origin.as_str())
.allowed_methods(vec!["GET", "POST"])
.allowed_headers(vec![http::header::AUTHORIZATION, http::header::ACCEPT])
.allowed_header(http::header::CONTENT_TYPE)
.max_age(3600);

let pool = get_pool();
App::new()
.app_data(web::Data::new(pool))
.wrap(cors)
.service(greet)
.service(handle_google_oauth_callback)
.service(login)
}
Loading
Loading