Skip to content
This repository has been archived by the owner on Dec 7, 2023. It is now read-only.

Commit

Permalink
feat(server): add session authentication
Browse files Browse the repository at this point in the history
With this change, the GraphQL endpoint is put behind an authentication
middleware. You are required to supply the `authorization` header with
the correct authentication token.

Tokens are stored as plain-text UUIDv4 in the database (for now), as
this authentication mechanism is not meant to secure the server from the
outside world, but rather to limit internal company-wide access.

In the future, the new `sessions` table will also be used to store
session-related configuration properties.

To facilitate this change, a new `RequestState` object is introduced
that is created for each incoming request. It contains a database
connection handle to perform the necessary queries, and the `Session`
object that will contain the preferences of the current active session
in the future.

There is also a new `createSession` endpoint, that will generate a new
session key that can be distributed within your organisation. Again, in
the future this will be extended to update session preferences.

There is no default session, so you'll have to create your first session
manually using a query such as `INSERT INTO sessions DEFAULT VALUES;`
(the table is configured to auto-generate UUIDv4 values for tokens).

Client-side support has yet to be implemented.

This is part of an ongoing effort described in
#19.
  • Loading branch information
JeanMertz committed Jul 21, 2019
1 parent 4ef1690 commit 411e8e2
Show file tree
Hide file tree
Showing 16 changed files with 292 additions and 89 deletions.
3 changes: 3 additions & 0 deletions Cargo.lock

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

5 changes: 4 additions & 1 deletion src/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ diesel = { version = "1.4", default-features = false, features = [
"chrono",
"postgres",
"r2d2",
"serde_json"
"serde_json",
"uuidv07"
] }
diesel-derive-enum = { version = "0.4", features = ["postgres"] }
diesel_migrations = "1.4"
Expand All @@ -47,10 +48,12 @@ lazy_static = "1.3"
openssl = "0.10"
paste = "0.1"
pulldown-cmark = { version = "0.5", default-features = false }
r2d2 = "0.8"
serde = { version = "1.0", default-features = false, features = ["derive"] }
serde_json = "1.0"
# see: http://git.io/fjPnd
tera = { git = "https://github.com/Keats/tera.git", branch = "v1" }
uuid = { version = "0.7.0", features = ["v4"] }

[dependencies.processor-git-clone-v1]
package = "automaat-processor-git-clone"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE sessions;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
CREATE TABLE sessions (
id Serial PRIMARY KEY,
token UUID NOT NULL DEFAULT gen_random_uuid()
);
76 changes: 42 additions & 34 deletions src/server/src/graphql.rs
Original file line number Diff line number Diff line change
@@ -1,29 +1,27 @@
use crate::models::NewGlobalVariable;
use crate::models::{NewGlobalVariable, Session};
use crate::resources::{
CreateJobFromTaskInput, CreateTaskInput, GlobalVariableInput, Job, NewJob, NewJobVariable,
NewTask, OnConflict, SearchTaskInput, Task,
};
use crate::schema::*;
use crate::State;
use crate::server::RequestState;
use diesel::prelude::*;
use juniper::{object, Context, FieldResult, RootNode, ID};
use std::convert::TryFrom;

impl Context for State {}
impl Context for RequestState {}

pub(crate) type Schema = RootNode<'static, QueryRoot, MutationRoot>;
pub(crate) struct QueryRoot;
pub(crate) struct MutationRoot;

#[object(Context = State)]
#[object(Context = RequestState)]
impl QueryRoot {
/// Return a list of tasks.
///
/// You can optionally filter the returned set of tasks by providing the
/// `SearchTaskInput` value.
fn tasks(context: &State, search: Option<SearchTaskInput>) -> FieldResult<Vec<Task>> {
let conn = context.pool.get()?;

fn tasks(context: &RequestState, search: Option<SearchTaskInput>) -> FieldResult<Vec<Task>> {
let mut query = tasks::table.order(tasks::id).into_boxed();

if let Some(search) = &search {
Expand All @@ -37,26 +35,25 @@ impl QueryRoot {
};
};

query.load(&conn).map_err(Into::into)
query.load(&context.conn).map_err(Into::into)
}

/// Return a list of jobs.
fn jobs(context: &State) -> FieldResult<Vec<Job>> {
let conn = context.pool.get()?;

jobs::table.order(jobs::id).load(&conn).map_err(Into::into)
fn jobs(context: &RequestState) -> FieldResult<Vec<Job>> {
jobs::table
.order(jobs::id)
.load(&context.conn)
.map_err(Into::into)
}

/// Return a single task, based on the task ID.
///
/// This query can return `null` if no task is found matching the
/// provided ID.
fn task(context: &State, id: ID) -> FieldResult<Option<Task>> {
let conn = context.pool.get()?;

fn task(context: &RequestState, id: ID) -> FieldResult<Option<Task>> {
tasks::table
.filter(tasks::id.eq(id.parse::<i32>()?))
.first(&conn)
.first(&context.conn)
.optional()
.map_err(Into::into)
}
Expand All @@ -65,43 +62,39 @@ impl QueryRoot {
///
/// This query can return `null` if no job is found matching the
/// provided ID.
fn job(context: &State, id: ID) -> FieldResult<Option<Job>> {
let conn = context.pool.get()?;

fn job(context: &RequestState, id: ID) -> FieldResult<Option<Job>> {
jobs::table
.filter(jobs::id.eq(id.parse::<i32>()?))
.first(&conn)
.first(&context.conn)
.optional()
.map_err(Into::into)
}
}

#[object(Context = State)]
#[object(Context = RequestState)]
impl MutationRoot {
/// Create a new task.
fn createTask(context: &State, task: CreateTaskInput) -> FieldResult<Task> {
let conn = context.pool.get()?;

NewTask::try_from(&task)?.create(&conn).map_err(Into::into)
fn createTask(context: &RequestState, task: CreateTaskInput) -> FieldResult<Task> {
NewTask::try_from(&task)?
.create(&context.conn)
.map_err(Into::into)
}

/// Create a job from an existing task ID.
///
/// Once the job is created, it will be scheduled to run immediately.
fn createJobFromTask(context: &State, job: CreateJobFromTaskInput) -> FieldResult<Job> {
let conn = context.pool.get()?;

fn createJobFromTask(context: &RequestState, job: CreateJobFromTaskInput) -> FieldResult<Job> {
let task = tasks::table
.filter(tasks::id.eq(job.task_id.parse::<i32>()?))
.first(&conn)?;
.first(&context.conn)?;

let variables = job
.variables
.iter()
.map(Into::into)
.collect::<Vec<NewJobVariable<'_>>>();

NewJob::create_from_task(&conn, &task, variables).map_err(Into::into)
NewJob::create_from_task(&context.conn, &task, variables).map_err(Into::into)
}

/// Create a new global variable.
Expand All @@ -112,16 +105,31 @@ impl MutationRoot {
/// By default, this mutation will return an error if the variable key
/// already exists. If you want to override an existing key, set the
/// `onConflict` key to `UPDATE`.
fn createGlobalVariable(context: &State, variable: GlobalVariableInput) -> FieldResult<bool> {
fn createGlobalVariable(
context: &RequestState,
variable: GlobalVariableInput,
) -> FieldResult<bool> {
use OnConflict::*;
let conn = context.pool.get()?;

let global_variable = NewGlobalVariable::from(&variable);
let global_variable = match &variable.on_conflict.as_ref().unwrap_or(&Abort) {
Abort => global_variable.create(&conn),
Update => global_variable.create_or_update(&conn),
Abort => global_variable.create(&context.conn),
Update => global_variable.create_or_update(&context.conn),
};

global_variable.map(|_| true).map_err(Into::into)
}

/// Create a new session.
///
/// The returned session token can be used to authenticate with the GraphQL
/// API by using the `Authorization` header.
///
/// In the future, this token can also be used to store user-related
/// preferences.
fn createSession(context: &RequestState) -> FieldResult<String> {
Session::create(&context.conn)
.map(|s| s.token.to_string())
.map_err(Into::into)
}
}
91 changes: 85 additions & 6 deletions src/server/src/handlers.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
use crate::graphql::Schema;
use crate::State;
use crate::models::Session;
use crate::server::{RequestState, ServerError, State};
use actix_web::web::{block, Data, Json};
use actix_web::{Error, HttpResponse};
use actix_web::{HttpRequest, HttpResponse};
use diesel::pg::PgConnection;
use futures::future::Future;
use juniper::http::{graphiql, playground, GraphQLRequest};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use std::sync::Arc;
use uuid::Uuid;

/// See: <https://tools.ietf.org/html/draft-inadarei-api-health-check-03>
#[derive(Debug, Deserialize, Serialize)]
Expand Down Expand Up @@ -40,12 +44,17 @@ pub(super) fn playground() -> HttpResponse {

pub(super) fn graphql(
state: Data<Arc<State>>,
request: Json<GraphQLRequest>,
(graphql, request): (Json<GraphQLRequest>, HttpRequest),
schema: Data<Arc<Schema>>,
) -> impl Future<Item = HttpResponse, Error = Error> {
) -> impl Future<Item = HttpResponse, Error = ServerError> {
let token = auth_token(&request);

block(move || {
let response = request.execute(&schema, &state);
serde_json::to_string(&response)
let conn = state.pool.get()?;
let session = authenticate(&token?, &conn)?;
let response = graphql.execute(&schema, &RequestState::new(conn, session));

serde_json::to_string(&response).map_err(Into::<ServerError>::into)
})
.map_err(Into::into)
.and_then(|response| {
Expand All @@ -67,3 +76,73 @@ pub(super) fn health() -> HttpResponse {
.header("Cache-Control", "no-cache")
.json(health)
}

fn authenticate(token: &str, conn: &PgConnection) -> Result<Session, ServerError> {
Uuid::from_str(token)
.ok()
.and_then(|token| Session::find_by_token(token, conn).ok())
.ok_or(ServerError::Authentication)
}

fn auth_token(request: &HttpRequest) -> Result<String, ServerError> {
use actix_web::http::header;

request
.headers()
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().map(str::to_owned).ok())
.ok_or(ServerError::Authentication)
}

#[cfg(test)]
mod tests {
use super::*;
use actix_web::test;
use diesel::prelude::*;
use diesel::result::Error;

fn connection() -> PgConnection {
PgConnection::establish("postgres://postgres@localhost").unwrap()
}

#[test]
#[should_panic]
fn test_authenticate_invalid_uuid() {
let _ = authenticate("invalid-uuid", &connection()).unwrap();
}

#[test]
#[should_panic]
fn test_authenticate_unknown_session() {
let uuid = Uuid::new_v4().to_string();
let _ = authenticate(&uuid, &connection()).unwrap();
}

#[test]
fn test_authenticate_known_session() {
let conn = connection();

conn.test_transaction::<_, Error, _>(|| {
let session = Session::create(&conn).unwrap();
let auth = authenticate(&session.token.to_string(), &conn).unwrap();

assert_eq!(session.token, auth.token);
Ok(())
});
}

#[test]
#[should_panic]
fn test_auth_token_missing() {
let req = test::TestRequest::default().to_http_request();

let _ = auth_token(&req).unwrap();
}

#[test]
fn test_auth_token_exists() {
let req = test::TestRequest::with_header("authorization", "token").to_http_request();

let _ = auth_token(&req).unwrap();
}
}
2 changes: 1 addition & 1 deletion src/server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ mod server;
mod worker;

use crate::processor::{Input as ProcessorInput, Processor};
use crate::server::{Server, State};
use crate::server::Server;
use crate::worker::Worker;
use diesel_migrations::embed_migrations;
use std::env;
Expand Down
6 changes: 4 additions & 2 deletions src/server/src/models.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
mod global_variable;
mod session;
mod variable_advertisement;

pub(crate) use self::global_variable::{GlobalVariable, NewGlobalVariable};
pub(crate) use self::variable_advertisement::{NewVariableAdvertisement, VariableAdvertisement};
pub(crate) use global_variable::{GlobalVariable, NewGlobalVariable};
pub(crate) use session::Session;
pub(crate) use variable_advertisement::{NewVariableAdvertisement, VariableAdvertisement};
29 changes: 29 additions & 0 deletions src/server/src/models/session.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use crate::schema::sessions;
use diesel::prelude::*;
use uuid::Uuid;

/// The model representing a session stored in the database.
#[derive(Clone, Copy, Debug, Identifiable, Queryable)]
#[table_name = "sessions"]
pub(crate) struct Session {
pub(crate) id: i32,
pub(crate) token: Uuid,
}

impl Session {
pub(crate) fn find_by_token(token: Uuid, conn: &PgConnection) -> QueryResult<Self> {
sessions::table
.filter(sessions::token.eq(token))
.first(conn)
}

/// Create a new session in the database.
///
/// All values will be set to their defaults, including generating a session
/// token in the database.
pub(crate) fn create(conn: &PgConnection) -> QueryResult<Self> {
diesel::insert_into(sessions::table)
.default_values()
.get_result(conn)
}
}
Loading

0 comments on commit 411e8e2

Please sign in to comment.