diff options
Diffstat (limited to 'rust/src')
-rw-r--r-- | rust/src/api_helpers.rs | 76 | ||||
-rw-r--r-- | rust/src/api_server.rs | 66 | ||||
-rw-r--r-- | rust/src/api_wrappers.rs | 352 | ||||
-rw-r--r-- | rust/src/auth.rs | 470 | ||||
-rw-r--r-- | rust/src/bin/fatcat-auth.rs | 134 | ||||
-rw-r--r-- | rust/src/bin/fatcat-export.rs | 18 | ||||
-rw-r--r-- | rust/src/bin/fatcatd.rs | 24 | ||||
-rw-r--r-- | rust/src/database_models.rs | 36 | ||||
-rw-r--r-- | rust/src/database_schema.rs | 18 | ||||
-rw-r--r-- | rust/src/lib.rs | 62 |
10 files changed, 1172 insertions, 84 deletions
diff --git a/rust/src/api_helpers.rs b/rust/src/api_helpers.rs index ff164bef..5ee529b9 100644 --- a/rust/src/api_helpers.rs +++ b/rust/src/api_helpers.rs @@ -205,29 +205,59 @@ fn test_hide_flags() { pub fn make_edit_context( conn: &DbConn, + editor_id: FatCatId, editgroup_id: Option<FatCatId>, autoaccept: bool, ) -> Result<EditContext> { - let editor_id = Uuid::parse_str("00000000-0000-0000-AAAA-000000000001")?; // TODO: auth let editgroup_id: FatCatId = match (editgroup_id, autoaccept) { (Some(eg), _) => eg, // If autoaccept and no editgroup_id passed, always create a new one for this transaction (None, true) => { let eg_row: EditgroupRow = diesel::insert_into(editgroup::table) - .values((editgroup::editor_id.eq(editor_id),)) + .values((editgroup::editor_id.eq(editor_id.to_uuid()),)) .get_result(conn)?; FatCatId::from_uuid(&eg_row.id) } - (None, false) => FatCatId::from_uuid(&get_or_create_editgroup(editor_id, conn)?), + (None, false) => FatCatId::from_uuid(&get_or_create_editgroup(editor_id.to_uuid(), conn)?), }; Ok(EditContext { - editor_id: FatCatId::from_uuid(&editor_id), + editor_id: editor_id, editgroup_id: editgroup_id, extra_json: None, autoaccept: autoaccept, }) } +pub fn create_editor( + conn: &DbConn, + username: String, + is_admin: bool, + is_bot: bool, +) -> Result<EditorRow> { + check_username(&username)?; + let ed: EditorRow = diesel::insert_into(editor::table) + .values(( + editor::username.eq(username), + editor::is_admin.eq(is_admin), + editor::is_bot.eq(is_bot), + )) + .get_result(conn)?; + Ok(ed) +} + +pub fn update_editor_username( + conn: &DbConn, + editor_id: FatCatId, + username: String, +) -> Result<EditorRow> { + check_username(&username)?; + diesel::update(editor::table.find(editor_id.to_uuid())) + .set(editor::username.eq(username)) + .execute(conn)?; + let editor: EditorRow = editor::table.find(editor_id.to_uuid()).get_result(conn)?; + Ok(editor) +} + /// This function should always be run within a transaction pub fn get_or_create_editgroup(editor_id: Uuid, conn: &DbConn) -> Result<Uuid> { // check for current active @@ -282,7 +312,7 @@ pub fn accept_editgroup(editgroup_id: FatCatId, conn: &DbConn) -> Result<Changel Ok(entry) } -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, PartialEq, Debug)] pub struct FatCatId(Uuid); impl ToString for FatCatId { @@ -327,6 +357,42 @@ pub fn uuid2fcid(id: &Uuid) -> String { BASE32_NOPAD.encode(raw).to_lowercase() } +pub fn check_username(raw: &str) -> Result<()> { + lazy_static! { + static ref RE: Regex = Regex::new(r"^[A-Za-z][A-Za-z0-9._-]{2,24}$").unwrap(); + } + if RE.is_match(raw) { + Ok(()) + } else { + Err(ErrorKind::MalformedExternalId(format!( + "not a valid username: '{}' (expected, eg, 'AcidBurn')", + raw + )) + .into()) + } +} + +#[test] +fn test_check_username() { + assert!(check_username("bnewbold").is_ok()); + assert!(check_username("BNEWBOLD").is_ok()); + assert!(check_username("admin").is_ok()); + assert!(check_username("friend-bot").is_ok()); + assert!(check_username("dog").is_ok()); + assert!(check_username("g_____").is_ok()); + assert!(check_username("bnewbold2-archive").is_ok()); + assert!(check_username("bnewbold2-internetarchive").is_ok()); + + assert!(check_username("").is_err()); + assert!(check_username("_").is_err()); + assert!(check_username("gg").is_err()); + assert!(check_username("adminadminadminadminadminadminadmin").is_err()); + assert!(check_username("bryan newbold").is_err()); + assert!(check_username("01234567-3456-6780").is_err()); + assert!(check_username(".admin").is_err()); + assert!(check_username("-bot").is_err()); +} + pub fn check_pmcid(raw: &str) -> Result<()> { lazy_static! { static ref RE: Regex = Regex::new(r"^PMC\d+$").unwrap(); diff --git a/rust/src/api_server.rs b/rust/src/api_server.rs index d264afbc..349c6a27 100644 --- a/rust/src/api_server.rs +++ b/rust/src/api_server.rs @@ -2,6 +2,7 @@ use api_entity_crud::EntityCrud; use api_helpers::*; +use auth::*; use chrono; use database_models::*; use database_schema::*; @@ -19,11 +20,12 @@ macro_rules! entity_batch_handler { &self, entity_list: &[models::$model], autoaccept: bool, + editor_id: FatCatId, editgroup_id: Option<FatCatId>, conn: &DbConn, ) -> Result<Vec<EntityEdit>> { - let edit_context = make_edit_context(conn, editgroup_id, autoaccept)?; + let edit_context = make_edit_context(conn, editor_id, editgroup_id, autoaccept)?; edit_context.check(&conn)?; let model_list: Vec<&models::$model> = entity_list.iter().map(|e| e).collect(); let edits = $model::db_create_batch(conn, &edit_context, model_list.as_slice())?; @@ -38,20 +40,10 @@ macro_rules! entity_batch_handler { } } -macro_rules! count_entity { - ($table:ident, $conn:expr) => {{ - let count: i64 = $table::table - .filter($table::is_live.eq(true)) - .filter($table::redirect_id.is_null()) - .count() - .first($conn)?; - count - }}; -} - #[derive(Clone)] pub struct Server { pub db_pool: ConnectionPool, + pub auth_confectionary: AuthConfectionary, } pub fn get_release_files( @@ -392,7 +384,7 @@ impl Server { ) -> Result<Editgroup> { let row: EditgroupRow = insert_into(editgroup::table) .values(( - editgroup::editor_id.eq(FatCatId::from_str(&entity.editor_id)?.to_uuid()), + editgroup::editor_id.eq(FatCatId::from_str(&entity.editor_id.unwrap())?.to_uuid()), editgroup::description.eq(entity.description), editgroup::extra_json.eq(entity.extra), )) @@ -400,7 +392,7 @@ impl Server { Ok(Editgroup { editgroup_id: Some(uuid2fcid(&row.id)), - editor_id: uuid2fcid(&row.editor_id), + editor_id: Some(uuid2fcid(&row.editor_id)), description: row.description, edits: None, extra: row.extra_json, @@ -475,7 +467,7 @@ impl Server { let eg = Editgroup { editgroup_id: Some(uuid2fcid(&row.id)), - editor_id: uuid2fcid(&row.editor_id), + editor_id: Some(uuid2fcid(&row.editor_id)), description: row.description, edits: Some(edits), extra: row.extra_json, @@ -485,12 +477,7 @@ impl Server { pub fn get_editor_handler(&self, editor_id: FatCatId, conn: &DbConn) -> Result<Editor> { let row: EditorRow = editor::table.find(editor_id.to_uuid()).first(conn)?; - - let ed = Editor { - editor_id: Some(uuid2fcid(&row.id)), - username: row.username, - }; - Ok(ed) + Ok(row.into_model()) } pub fn get_editor_changelog_handler( @@ -552,6 +539,43 @@ impl Server { Ok(entry) } + /// This helper either finds an Editor model by OIDC parameters (eg, remote domain and + /// identifier), or creates one and inserts the appropriate auth rows. The semantics are + /// basically an "upsert" of signup/account-creation. + /// Returns an editor model and boolean flag indicating whether a new editor was created or + /// not. + /// If this function creates an editor, it sets the username to + /// "{preferred_username}-{provider}"; the intent is for this to be temporary but unique. Might + /// look like "bnewbold-github", or might look like "895139824-github". This is a hack to make + /// check/creation idempotent. + pub fn auth_oidc_handler(&self, params: AuthOidc, conn: &DbConn) -> Result<(Editor, bool)> { + let existing: Vec<(EditorRow, AuthOidcRow)> = editor::table + .inner_join(auth_oidc::table) + .filter(auth_oidc::oidc_sub.eq(params.sub.clone())) + .filter(auth_oidc::oidc_iss.eq(params.iss.clone())) + .load(conn)?; + + let (editor_row, created): (EditorRow, bool) = match existing.first() { + Some((editor, _)) => (editor.clone(), false), + None => { + let username = format!("{}-{}", params.preferred_username, params.provider); + let editor = create_editor(conn, username, false, false)?; + // create an auth login row so the user can log back in + diesel::insert_into(auth_oidc::table) + .values(( + auth_oidc::editor_id.eq(editor.id), + auth_oidc::provider.eq(params.provider), + auth_oidc::oidc_iss.eq(params.iss), + auth_oidc::oidc_sub.eq(params.sub), + )) + .execute(conn)?; + (editor, true) + } + }; + + Ok((editor_row.into_model(), created)) + } + entity_batch_handler!(create_container_batch_handler, ContainerEntity); entity_batch_handler!(create_creator_batch_handler, CreatorEntity); entity_batch_handler!(create_file_batch_handler, FileEntity); diff --git a/rust/src/api_wrappers.rs b/rust/src/api_wrappers.rs index cf696d15..f03d4041 100644 --- a/rust/src/api_wrappers.rs +++ b/rust/src/api_wrappers.rs @@ -3,6 +3,7 @@ use api_entity_crud::EntityCrud; use api_helpers::*; use api_server::Server; +use auth::*; use database_models::EntityEditRow; use diesel::Connection; use errors::*; @@ -80,14 +81,18 @@ macro_rules! wrap_entity_handlers { &self, entity: models::$model, editgroup_id: Option<String>, - _context: &Context, + context: &Context, ) -> Box<Future<Item = $post_resp, Error = ApiError> + Send> { let conn = self.db_pool.get().expect("db_pool error"); let ret = match conn.transaction(|| { + let auth_context = self.auth_confectionary.require_auth(&conn, &context.auth_data, Some(stringify!($post_fn)))?; + auth_context.require_role(FatcatRole::Editor)?; let editgroup_id = if let Some(s) = editgroup_id { - Some(FatCatId::from_str(&s)?) + let eg_id = FatCatId::from_str(&s)?; + auth_context.require_editgroup(&conn, eg_id)?; + Some(eg_id) } else { None }; - let edit_context = make_edit_context(&conn, editgroup_id, false)?; + let edit_context = make_edit_context(&conn, auth_context.editor_id, editgroup_id, false)?; edit_context.check(&conn)?; entity.db_create(&conn, &edit_context)?.into_model() }) { @@ -108,6 +113,11 @@ macro_rules! wrap_entity_handlers { $post_resp::BadRequest(ErrorResponse { message: e.to_string() }), Err(Error(ErrorKind::EditgroupAlreadyAccepted(e), _)) => $post_resp::BadRequest(ErrorResponse { message: e.to_string() }), + Err(Error(ErrorKind::InvalidCredentials(e), _)) => + // TODO: why can't I NotAuthorized here? + $post_resp::Forbidden(ErrorResponse { message: e.to_string() }), + Err(Error(ErrorKind::InsufficientPrivileges(e), _)) => + $post_resp::Forbidden(ErrorResponse { message: e.to_string() }), Err(Error(ErrorKind::OtherBadRequest(e), _)) => $post_resp::BadRequest(ErrorResponse { message: e.to_string() }), Err(e) => { @@ -123,14 +133,18 @@ macro_rules! wrap_entity_handlers { entity_list: &Vec<models::$model>, autoaccept: Option<bool>, editgroup_id: Option<String>, - _context: &Context, + context: &Context, ) -> Box<Future<Item = $post_batch_resp, Error = ApiError> + Send> { let conn = self.db_pool.get().expect("db_pool error"); let ret = match conn.transaction(|| { + let auth_context = self.auth_confectionary.require_auth(&conn, &context.auth_data, Some(stringify!($post_batch_fn)))?; + auth_context.require_role(FatcatRole::Editor)?; let editgroup_id = if let Some(s) = editgroup_id { - Some(FatCatId::from_str(&s)?) + let eg_id = FatCatId::from_str(&s)?; + auth_context.require_editgroup(&conn, eg_id)?; + Some(eg_id) } else { None }; - self.$post_batch_handler(entity_list, autoaccept.unwrap_or(false), editgroup_id, &conn) + self.$post_batch_handler(entity_list, autoaccept.unwrap_or(false), auth_context.editor_id, editgroup_id, &conn) }) { Ok(edit) => $post_batch_resp::CreatedEntities(edit), @@ -149,6 +163,11 @@ macro_rules! wrap_entity_handlers { $post_batch_resp::BadRequest(ErrorResponse { message: e.to_string() }), Err(Error(ErrorKind::EditgroupAlreadyAccepted(e), _)) => $post_batch_resp::BadRequest(ErrorResponse { message: e.to_string() }), + Err(Error(ErrorKind::InvalidCredentials(e), _)) => + // TODO: why can't I NotAuthorized here? + $post_batch_resp::Forbidden(ErrorResponse { message: e.to_string() }), + Err(Error(ErrorKind::InsufficientPrivileges(e), _)) => + $post_batch_resp::Forbidden(ErrorResponse { message: e.to_string() }), Err(Error(ErrorKind::OtherBadRequest(e), _)) => $post_batch_resp::BadRequest(ErrorResponse { message: e.to_string() }), Err(e) => { @@ -164,15 +183,19 @@ macro_rules! wrap_entity_handlers { ident: String, entity: models::$model, editgroup_id: Option<String>, - _context: &Context, + context: &Context, ) -> Box<Future<Item = $update_resp, Error = ApiError> + Send> { let conn = self.db_pool.get().expect("db_pool error"); let ret = match conn.transaction(|| { + let auth_context = self.auth_confectionary.require_auth(&conn, &context.auth_data, Some(stringify!($update_fn)))?; + auth_context.require_role(FatcatRole::Editor)?; let entity_id = FatCatId::from_str(&ident)?; let editgroup_id = if let Some(s) = editgroup_id { - Some(FatCatId::from_str(&s)?) + let eg_id = FatCatId::from_str(&s)?; + auth_context.require_editgroup(&conn, eg_id)?; + Some(eg_id) } else { None }; - let edit_context = make_edit_context(&conn, editgroup_id, false)?; + let edit_context = make_edit_context(&conn, auth_context.editor_id, editgroup_id, false)?; edit_context.check(&conn)?; entity.db_update(&conn, &edit_context, entity_id)?.into_model() }) { @@ -199,6 +222,11 @@ macro_rules! wrap_entity_handlers { $update_resp::BadRequest(ErrorResponse { message: e.to_string() }), Err(Error(ErrorKind::OtherBadRequest(e), _)) => $update_resp::BadRequest(ErrorResponse { message: e.to_string() }), + Err(Error(ErrorKind::InvalidCredentials(e), _)) => + // TODO: why can't I NotAuthorized here? + $update_resp::Forbidden(ErrorResponse { message: e.to_string() }), + Err(Error(ErrorKind::InsufficientPrivileges(e), _)) => + $update_resp::Forbidden(ErrorResponse { message: e.to_string() }), Err(e) => { error!("{}", e); $update_resp::GenericError(ErrorResponse { message: e.to_string() }) @@ -211,16 +239,22 @@ macro_rules! wrap_entity_handlers { &self, ident: String, editgroup_id: Option<String>, - _context: &Context, + context: &Context, ) -> Box<Future<Item = $delete_resp, Error = ApiError> + Send> { let conn = self.db_pool.get().expect("db_pool error"); let ret = match conn.transaction(|| { + let auth_context = self.auth_confectionary.require_auth(&conn, &context.auth_data, Some(stringify!($delete_fn)))?; + auth_context.require_role(FatcatRole::Editor)?; let entity_id = FatCatId::from_str(&ident)?; let editgroup_id: Option<FatCatId> = match editgroup_id { - Some(s) => Some(FatCatId::from_str(&s)?), + Some(s) => { + let editgroup_id = FatCatId::from_str(&s)?; + auth_context.require_editgroup(&conn, editgroup_id)?; + Some(editgroup_id) + }, None => None, }; - let edit_context = make_edit_context(&conn, editgroup_id, false)?; + let edit_context = make_edit_context(&conn, auth_context.editor_id, editgroup_id, false)?; edit_context.check(&conn)?; $model::db_delete(&conn, &edit_context, entity_id)?.into_model() }) { @@ -243,6 +277,11 @@ macro_rules! wrap_entity_handlers { $delete_resp::BadRequest(ErrorResponse { message: e.to_string() }), Err(Error(ErrorKind::OtherBadRequest(e), _)) => $delete_resp::BadRequest(ErrorResponse { message: e.to_string() }), + Err(Error(ErrorKind::InvalidCredentials(e), _)) => + // TODO: why can't I NotAuthorized here? + $delete_resp::Forbidden(ErrorResponse { message: e.to_string() }), + Err(Error(ErrorKind::InsufficientPrivileges(e), _)) => + $delete_resp::Forbidden(ErrorResponse { message: e.to_string() }), Err(e) => { error!("{}", e); $delete_resp::GenericError(ErrorResponse { message: e.to_string() }) @@ -353,16 +392,19 @@ macro_rules! wrap_entity_handlers { fn $delete_edit_fn( &self, edit_id: String, - _context: &Context, + context: &Context, ) -> Box<Future<Item = $delete_edit_resp, Error = ApiError> + Send> { let conn = self.db_pool.get().expect("db_pool error"); let ret = match conn.transaction(|| { let edit_id = Uuid::from_str(&edit_id)?; + let auth_context = self.auth_confectionary.require_auth(&conn, &context.auth_data, Some(stringify!($delete_edit_fn)))?; + auth_context.require_role(FatcatRole::Editor)?; + let edit = $model::db_get_edit(&conn, edit_id)?; + auth_context.require_editgroup(&conn, FatCatId::from_uuid(&edit.editgroup_id))?; $model::db_delete_edit(&conn, edit_id) }) { Ok(()) => - $delete_edit_resp::DeletedEdit(Success { message: format!("Successfully deleted work-in-progress {} edit: {}", stringify!($model), edit_id) } ), - Err(Error(ErrorKind::Diesel(::diesel::result::Error::NotFound), _)) => + $delete_edit_resp::DeletedEdit(Success { message: format!("Successfully deleted work-in-progress {} edit: {}", stringify!($model), edit_id) } ), Err(Error(ErrorKind::Diesel(::diesel::result::Error::NotFound), _)) => $delete_edit_resp::NotFound(ErrorResponse { message: format!("No such {} edit: {}", stringify!($model), edit_id) }), Err(Error(ErrorKind::Diesel(e), _)) => $delete_edit_resp::BadRequest(ErrorResponse { message: e.to_string() }), @@ -370,6 +412,11 @@ macro_rules! wrap_entity_handlers { $delete_edit_resp::BadRequest(ErrorResponse { message: e.to_string() }), Err(Error(ErrorKind::OtherBadRequest(e), _)) => $delete_edit_resp::BadRequest(ErrorResponse { message: e.to_string() }), + Err(Error(ErrorKind::InvalidCredentials(e), _)) => + // TODO: why can't I NotAuthorized here? + $delete_edit_resp::Forbidden(ErrorResponse { message: e.to_string() }), + Err(Error(ErrorKind::InsufficientPrivileges(e), _)) => + $delete_edit_resp::Forbidden(ErrorResponse { message: e.to_string() }), Err(e) => { error!("{}", e); $delete_edit_resp::GenericError(ErrorResponse { message: e.to_string() }) @@ -856,14 +903,98 @@ impl Api for Server { Box::new(futures::done(Ok(ret))) } + /// For now, only implements updating username + fn update_editor( + &self, + editor_id: String, + editor: models::Editor, + context: &Context, + ) -> Box<Future<Item = UpdateEditorResponse, Error = ApiError> + Send> { + let conn = self.db_pool.get().expect("db_pool error"); + let ret = match conn.transaction(|| { + if Some(editor_id.clone()) != editor.editor_id { + return Err( + ErrorKind::OtherBadRequest("editor_id doesn't match".to_string()).into(), + ); + } + let auth_context = self.auth_confectionary.require_auth( + &conn, + &context.auth_data, + Some("update_editor"), + )?; + let editor_id = FatCatId::from_str(&editor_id)?; + // DANGER! these permissions are for username updates only! + if editor_id == auth_context.editor_id { + // self edit of username allowed + auth_context.require_role(FatcatRole::Editor)?; + } else { + // admin can update any username + auth_context.require_role(FatcatRole::Admin)?; + }; + update_editor_username(&conn, editor_id, editor.username).map(|e| e.into_model()) + }) { + Ok(editor) => UpdateEditorResponse::UpdatedEditor(editor), + Err(Error(ErrorKind::Diesel(e), _)) => { + UpdateEditorResponse::BadRequest(ErrorResponse { + message: e.to_string(), + }) + } + Err(Error(ErrorKind::Uuid(e), _)) => UpdateEditorResponse::BadRequest(ErrorResponse { + message: e.to_string(), + }), + Err(Error(ErrorKind::InvalidFatcatId(e), _)) => { + UpdateEditorResponse::BadRequest(ErrorResponse { + message: ErrorKind::InvalidFatcatId(e).to_string(), + }) + } + Err(Error(ErrorKind::MalformedExternalId(e), _)) => { + UpdateEditorResponse::BadRequest(ErrorResponse { + message: e.to_string(), + }) + } + Err(Error(ErrorKind::InvalidCredentials(e), _)) => + // TODO: why can't I NotAuthorized here? + { + UpdateEditorResponse::Forbidden(ErrorResponse { + message: e.to_string(), + }) + } + Err(Error(ErrorKind::InsufficientPrivileges(e), _)) => { + UpdateEditorResponse::Forbidden(ErrorResponse { + message: e.to_string(), + }) + } + Err(Error(ErrorKind::OtherBadRequest(e), _)) => { + UpdateEditorResponse::BadRequest(ErrorResponse { + message: e.to_string(), + }) + } + Err(e) => { + error!("{}", e); + UpdateEditorResponse::GenericError(ErrorResponse { + message: e.to_string(), + }) + } + }; + Box::new(futures::done(Ok(ret))) + } + fn accept_editgroup( &self, editgroup_id: String, - _context: &Context, + context: &Context, ) -> Box<Future<Item = AcceptEditgroupResponse, Error = ApiError> + Send> { let conn = self.db_pool.get().expect("db_pool error"); let ret = match conn.transaction(|| { let editgroup_id = FatCatId::from_str(&editgroup_id)?; + let auth_context = self.auth_confectionary.require_auth( + &conn, + &context.auth_data, + Some("accept_editgroup"), + )?; + auth_context.require_role(FatcatRole::Admin)?; + // NOTE: this is currently redundant, but zero-cost + auth_context.require_editgroup(&conn, editgroup_id)?; self.accept_editgroup_handler(editgroup_id, &conn) }) { Ok(()) => AcceptEditgroupResponse::MergedSuccessfully(Success { @@ -879,6 +1010,16 @@ impl Api for Server { message: ErrorKind::EditgroupAlreadyAccepted(e).to_string(), }) } + Err(Error(ErrorKind::InvalidCredentials(e), _)) => { + AcceptEditgroupResponse::Forbidden(ErrorResponse { + message: e.to_string(), + }) + } + Err(Error(ErrorKind::InsufficientPrivileges(e), _)) => { + AcceptEditgroupResponse::Forbidden(ErrorResponse { + message: e.to_string(), + }) + } Err(e) => AcceptEditgroupResponse::GenericError(ErrorResponse { message: e.to_string(), }), @@ -916,11 +1057,42 @@ impl Api for Server { fn create_editgroup( &self, entity: models::Editgroup, - _context: &Context, + context: &Context, ) -> Box<Future<Item = CreateEditgroupResponse, Error = ApiError> + Send> { let conn = self.db_pool.get().expect("db_pool error"); - let ret = match conn.transaction(|| self.create_editgroup_handler(entity, &conn)) { + let ret = match conn.transaction(|| { + let auth_context = self.auth_confectionary.require_auth( + &conn, + &context.auth_data, + Some("create_editgroup"), + )?; + auth_context.require_role(FatcatRole::Editor)?; + let mut entity = entity.clone(); + match entity.editor_id.clone() { + Some(editor_id) => { + if !auth_context.has_role(FatcatRole::Admin) { + if editor_id != auth_context.editor_id.to_string() { + bail!("not authorized to create editgroups in others' names"); + } + } + } + None => { + entity.editor_id = Some(auth_context.editor_id.to_string()); + } + }; + self.create_editgroup_handler(entity, &conn) + }) { Ok(eg) => CreateEditgroupResponse::SuccessfullyCreated(eg), + Err(Error(ErrorKind::InvalidCredentials(e), _)) => { + CreateEditgroupResponse::Forbidden(ErrorResponse { + message: e.to_string(), + }) + } + Err(Error(ErrorKind::InsufficientPrivileges(e), _)) => { + CreateEditgroupResponse::Forbidden(ErrorResponse { + message: e.to_string(), + }) + } Err(e) => // TODO: dig in to error type here { @@ -974,4 +1146,148 @@ impl Api for Server { }; Box::new(futures::done(Ok(ret))) } + + fn auth_oidc( + &self, + params: models::AuthOidc, + context: &Context, + ) -> Box<Future<Item = AuthOidcResponse, Error = ApiError> + Send> { + let conn = self.db_pool.get().expect("db_pool error"); + let ret = match conn.transaction(|| { + let auth_context = self.auth_confectionary.require_auth( + &conn, + &context.auth_data, + Some("auth_oidc"), + )?; + auth_context.require_role(FatcatRole::Superuser)?; + let (editor, created) = self.auth_oidc_handler(params, &conn)?; + // create an auth token with 31 day duration + let token = self.auth_confectionary.create_token( + FatCatId::from_str(&editor.editor_id.clone().unwrap())?, + Some(chrono::Duration::days(31)), + )?; + let result = AuthOidcResult { editor, token }; + Ok((result, created)) + }) { + Ok((result, true)) => AuthOidcResponse::Created(result), + Ok((result, false)) => AuthOidcResponse::Found(result), + Err(Error(ErrorKind::Diesel(e), _)) => AuthOidcResponse::BadRequest(ErrorResponse { + message: e.to_string(), + }), + Err(Error(ErrorKind::Uuid(e), _)) => AuthOidcResponse::BadRequest(ErrorResponse { + message: e.to_string(), + }), + Err(Error(ErrorKind::InvalidFatcatId(e), _)) => { + AuthOidcResponse::BadRequest(ErrorResponse { + message: ErrorKind::InvalidFatcatId(e).to_string(), + }) + } + Err(Error(ErrorKind::MalformedExternalId(e), _)) => { + AuthOidcResponse::BadRequest(ErrorResponse { + message: e.to_string(), + }) + } + Err(Error(ErrorKind::MalformedChecksum(e), _)) => { + AuthOidcResponse::BadRequest(ErrorResponse { + message: e.to_string(), + }) + } + Err(Error(ErrorKind::NotInControlledVocabulary(e), _)) => { + AuthOidcResponse::BadRequest(ErrorResponse { + message: e.to_string(), + }) + } + Err(Error(ErrorKind::EditgroupAlreadyAccepted(e), _)) => { + AuthOidcResponse::BadRequest(ErrorResponse { + message: e.to_string(), + }) + } + Err(Error(ErrorKind::InvalidCredentials(e), _)) => + // TODO: why can't I NotAuthorized here? + { + AuthOidcResponse::Forbidden(ErrorResponse { + message: e.to_string(), + }) + } + Err(Error(ErrorKind::InsufficientPrivileges(e), _)) => { + AuthOidcResponse::Forbidden(ErrorResponse { + message: e.to_string(), + }) + } + Err(Error(ErrorKind::OtherBadRequest(e), _)) => { + AuthOidcResponse::BadRequest(ErrorResponse { + message: e.to_string(), + }) + } + Err(e) => { + error!("{}", e); + AuthOidcResponse::GenericError(ErrorResponse { + message: e.to_string(), + }) + } + }; + Box::new(futures::done(Ok(ret))) + } + + fn auth_check( + &self, + role: Option<String>, + context: &Context, + ) -> Box<Future<Item = AuthCheckResponse, Error = ApiError> + Send> { + let conn = self.db_pool.get().expect("db_pool error"); + let ret = match conn.transaction(|| { + let auth_context = self.auth_confectionary.require_auth( + &conn, + &context.auth_data, + Some("auth_check"), + )?; + if let Some(role) = role { + let role = match role.to_lowercase().as_ref() { + "superuser" => FatcatRole::Superuser, + "admin" => FatcatRole::Admin, + "editor" => FatcatRole::Editor, + "bot" => FatcatRole::Bot, + "human" => FatcatRole::Human, + "public" => FatcatRole::Public, + _ => bail!("unknown auth role: {}", role), + }; + auth_context.require_role(role)?; + }; + Ok(()) + }) { + Ok(()) => AuthCheckResponse::Success(Success { + message: "auth check successful!".to_string(), + }), + Err(Error(ErrorKind::Diesel(e), _)) => AuthCheckResponse::BadRequest(ErrorResponse { + message: e.to_string(), + }), + Err(Error(ErrorKind::Uuid(e), _)) => AuthCheckResponse::BadRequest(ErrorResponse { + message: e.to_string(), + }), + Err(Error(ErrorKind::InvalidCredentials(e), _)) => + // TODO: why can't I NotAuthorized here? + { + AuthCheckResponse::Forbidden(ErrorResponse { + message: e.to_string(), + }) + } + Err(Error(ErrorKind::InsufficientPrivileges(e), _)) => { + AuthCheckResponse::Forbidden(ErrorResponse { + message: e.to_string(), + }) + } + Err(Error(ErrorKind::OtherBadRequest(e), _)) => { + AuthCheckResponse::BadRequest(ErrorResponse { + message: e.to_string(), + }) + } + Err(e) => { + error!("{}", e); + AuthCheckResponse::GenericError(ErrorResponse { + message: e.to_string(), + }) + } + }; + Box::new(futures::done(Ok(ret))) + } } diff --git a/rust/src/auth.rs b/rust/src/auth.rs new file mode 100644 index 00000000..d4e03ecf --- /dev/null +++ b/rust/src/auth.rs @@ -0,0 +1,470 @@ +//! Editor bearer token authentication + +use data_encoding::BASE64; +use macaroon::{Format, Macaroon, Verifier}; +use std::fmt; +use swagger::auth::{AuthData, Authorization, Scopes}; + +use api_helpers::*; +use chrono::prelude::*; +use database_models::*; +use database_schema::*; +use diesel; +use diesel::prelude::*; +use errors::*; +use std::collections::HashMap; +use std::str::FromStr; + +// 32 bytes max (!) +static DUMMY_KEY: &[u8] = b"dummy-key-a-one-two-three-a-la"; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum FatcatRole { + Public, + Editor, + Bot, + Human, + Admin, + Superuser, +} + +#[derive(Clone)] +pub struct AuthContext { + pub editor_id: FatCatId, + editor_row: EditorRow, +} + +impl AuthContext { + pub fn has_role(&self, role: FatcatRole) -> bool { + if !self.editor_row.is_active { + // if account is disabled, only allow public role + return role == FatcatRole::Public; + } + if self.editor_row.is_superuser { + return true; + } + match role { + FatcatRole::Public => true, + FatcatRole::Editor => true, + FatcatRole::Bot => self.editor_row.is_bot, + FatcatRole::Human => !self.editor_row.is_bot, + FatcatRole::Admin => self.editor_row.is_admin, + FatcatRole::Superuser => self.editor_row.is_superuser, + } + } + + pub fn require_role(&self, role: FatcatRole) -> Result<()> { + match self.has_role(role) { + true => Ok(()), + false => Err(ErrorKind::InsufficientPrivileges(format!( + "doesn't have required role: {:?}", + role + )) + .into()), + } + } + + pub fn require_editgroup(&self, conn: &DbConn, editgroup_id: FatCatId) -> Result<()> { + if self.has_role(FatcatRole::Admin) { + return Ok(()); + } + let editgroup: EditgroupRow = editgroup::table + .find(editgroup_id.to_uuid()) + .get_result(conn)?; + match editgroup.editor_id == self.editor_id.to_uuid() { + true => Ok(()), + false => Err(ErrorKind::InsufficientPrivileges( + "editor does not own this editgroup".to_string(), + ) + .into()), + } + } +} + +#[derive(Debug)] +pub struct AuthError { + msg: String, +} + +impl fmt::Display for AuthError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "AuthError: {}", &self.msg) + } +} + +impl iron::Error for AuthError { + fn description(&self) -> &str { + &self.msg + } + fn cause(&self) -> Option<&iron::Error> { + None + } +} + +fn new_auth_ironerror(m: &str) -> iron::error::IronError { + iron::error::IronError::new( + AuthError { msg: m.to_string() }, + (iron::status::BadRequest, m.to_string()), + ) +} + +#[derive(Debug)] +pub struct OpenAuthMiddleware; + +impl OpenAuthMiddleware { + /// Create a middleware that authorizes with the configured subject. + pub fn new() -> OpenAuthMiddleware { + OpenAuthMiddleware + } +} + +impl iron::middleware::BeforeMiddleware for OpenAuthMiddleware { + fn before(&self, req: &mut iron::Request) -> iron::IronResult<()> { + req.extensions.insert::<Authorization>(Authorization { + subject: "undefined".to_string(), + scopes: Scopes::All, + issuer: None, + }); + Ok(()) + } +} + +#[derive(Debug)] +pub struct MacaroonAuthMiddleware; + +impl MacaroonAuthMiddleware { + pub fn new() -> MacaroonAuthMiddleware { + MacaroonAuthMiddleware + } +} +impl iron::middleware::BeforeMiddleware for MacaroonAuthMiddleware { + fn before(&self, req: &mut iron::Request) -> iron::IronResult<()> { + // Structure here is sorta funky because we might some day actually want to parse token + // here in some way + let token: Option<String> = match req.extensions.get::<AuthData>() { + Some(AuthData::ApiKey(header)) => { + let header: Vec<String> = + header.split_whitespace().map(|s| s.to_string()).collect(); + if !(header.len() == 2 && header[0] == "Bearer") { + return Err(new_auth_ironerror("invalid bearer auth HTTP Header")); + } + Some(header[1].to_string()) + } + None => None, + _ => { + return Err(new_auth_ironerror( + "auth HTTP Header should be empty or API token", + )); + } + }; + if let Some(_token) = token { + req.extensions.insert::<Authorization>(Authorization { + // This is just a dummy; all actual authentication happens later + subject: "undefined".to_string(), + scopes: Scopes::All, + issuer: None, + }); + }; + Ok(()) + } +} + +#[derive(Clone)] +pub struct AuthConfectionary { + pub location: String, + pub identifier: String, + pub key: Vec<u8>, + pub root_keys: HashMap<String, Vec<u8>>, +} + +impl AuthConfectionary { + pub fn new( + location: String, + identifier: String, + key_base64: String, + ) -> Result<AuthConfectionary> { + macaroon::initialize().unwrap(); + let key = BASE64.decode(key_base64.as_bytes())?; + let mut root_keys = HashMap::new(); + root_keys.insert(identifier.clone(), key.clone()); + Ok(AuthConfectionary { + location: location, + identifier: identifier, + key: key, + root_keys: root_keys, + }) + } + + pub fn new_dummy() -> AuthConfectionary { + AuthConfectionary::new( + "test.fatcat.wiki".to_string(), + "dummy".to_string(), + BASE64.encode(DUMMY_KEY), + ) + .unwrap() + } + + pub fn add_keypair(&mut self, identifier: String, key_base64: String) -> Result<()> { + let key = BASE64.decode(key_base64.as_bytes())?; + self.root_keys.insert(identifier, key); + Ok(()) + } + + pub fn create_token( + &self, + editor_id: FatCatId, + duration: Option<chrono::Duration>, + ) -> Result<String> { + let mut mac = Macaroon::create(&self.location, &self.key, &self.identifier) + .expect("Macaroon creation"); + mac.add_first_party_caveat(&format!("editor_id = {}", editor_id.to_string())); + let now_utc = Utc::now(); + let now = now_utc.to_rfc3339_opts(SecondsFormat::Secs, true); + mac.add_first_party_caveat(&format!("time > {}", now)); + if let Some(duration) = duration { + let expires = now_utc + duration; + mac.add_first_party_caveat(&format!( + "time < {:?}", + &expires.to_rfc3339_opts(SecondsFormat::Secs, true) + )); + }; + let raw = mac.serialize(Format::V2).expect("macaroon serialization"); + Ok(BASE64.encode(&raw)) + } + + pub fn parse_macaroon_token( + &self, + conn: &DbConn, + s: &str, + endpoint: Option<&str>, + ) -> Result<EditorRow> { + let raw = BASE64.decode(s.as_bytes())?; + let mac = match Macaroon::deserialize(&raw) { + Ok(m) => m, + Err(e) => { + // TODO: should be "chaining" here + return Err(ErrorKind::InvalidCredentials(format!( + "macaroon deserialize error: {:?}", + e + )) + .into()); + } + }; + let mac = match mac.validate() { + Ok(m) => m, + Err(e) => { + // TODO: should be "chaining" here + return Err(ErrorKind::InvalidCredentials(format!( + "macaroon validate error: {:?}", + e + )) + .into()); + } + }; + let mut verifier = Verifier::new(); + let mut editor_id: Option<FatCatId> = None; + for caveat in mac.first_party_caveats() { + if caveat.predicate().starts_with("editor_id = ") { + editor_id = Some(FatCatId::from_str(caveat.predicate().get(12..).unwrap())?); + break; + } + } + let editor_id = match editor_id { + Some(id) => id, + None => { + return Err(ErrorKind::InvalidCredentials( + "expected an editor_id caveat".to_string(), + ) + .into()); + } + }; + verifier.satisfy_exact(&format!("editor_id = {}", editor_id.to_string())); + if let Some(endpoint) = endpoint { + // API endpoint + verifier.satisfy_exact(&format!("endpoint = {}", endpoint)); + } + let mut created: Option<DateTime<Utc>> = None; + for caveat in mac.first_party_caveats() { + if caveat.predicate().starts_with("time > ") { + created = Some( + DateTime::parse_from_rfc3339(caveat.predicate().get(7..).unwrap()) + .unwrap() + .with_timezone(&Utc), + ); + break; + } + } + let created = match created { + Some(c) => c, + None => { + return Err(ErrorKind::InvalidCredentials( + "expected a 'created' (time >) caveat".to_string(), + ) + .into()); + } + }; + verifier.satisfy_exact(&format!( + "time > {}", + created.to_rfc3339_opts(SecondsFormat::Secs, true) + )); + let editor: EditorRow = editor::table.find(&editor_id.to_uuid()).get_result(conn)?; + let auth_epoch = DateTime::<Utc>::from_utc(editor.auth_epoch, Utc); + // allow a second of wiggle room for precision and, eg, tests + if created < (auth_epoch - chrono::Duration::seconds(1)) { + return Err(ErrorKind::InvalidCredentials( + "token created before current auth_epoch (was probably revoked by editor)" + .to_string(), + ) + .into()); + } + verifier.satisfy_general(|p: &str| -> bool { + // not expired (based on time) + if p.starts_with("time < ") { + let expires: DateTime<Utc> = DateTime::parse_from_rfc3339(p.get(7..).unwrap()) + .unwrap() + .with_timezone(&Utc); + expires < Utc::now() + } else { + false + } + }); + let verify_key = match self.root_keys.get(mac.identifier()) { + Some(key) => key, + None => { + return Err(ErrorKind::InvalidCredentials(format!( + "no valid auth signing key for identifier: {}", + mac.identifier() + )) + .into()); + } + }; + match mac.verify(verify_key, &mut verifier) { + Ok(true) => (), + Ok(false) => { + return Err(ErrorKind::InvalidCredentials( + "auth token (macaroon) not valid (signature and/or caveats failed)".to_string(), + ) + .into()); + } + Err(e) => { + // TODO: chain + return Err( + ErrorKind::InvalidCredentials(format!("token parsing failed: {:?}", e)).into(), + ); + } + } + Ok(editor) + } + + pub fn parse_swagger( + &self, + conn: &DbConn, + auth_data: &Option<AuthData>, + endpoint: Option<&str>, + ) -> Result<Option<AuthContext>> { + let token: Option<String> = match auth_data { + Some(AuthData::ApiKey(header)) => { + let header: Vec<String> = + header.split_whitespace().map(|s| s.to_string()).collect(); + if !(header.len() == 2 && header[0] == "Bearer") { + return Err(ErrorKind::InvalidCredentials( + "invalid Bearer Auth HTTP header".to_string(), + ) + .into()); + } + Some(header[1].clone()) + } + None => None, + _ => { + return Err(ErrorKind::InvalidCredentials( + "Authentication HTTP Header should either be empty or a Beaerer API key" + .to_string(), + ) + .into()); + } + }; + let token = match token { + Some(t) => t, + None => return Ok(None), + }; + let editor_row = self.parse_macaroon_token(conn, &token, endpoint)?; + Ok(Some(AuthContext { + editor_id: FatCatId::from_uuid(&editor_row.id), + editor_row: editor_row, + })) + } + + pub fn require_auth( + &self, + conn: &DbConn, + auth_data: &Option<AuthData>, + endpoint: Option<&str>, + ) -> Result<AuthContext> { + match self.parse_swagger(conn, auth_data, endpoint)? { + Some(auth) => Ok(auth), + None => Err(ErrorKind::InvalidCredentials("no token supplied".to_string()).into()), + } + } + + // TODO: refactor out of this file? + /// Only used from CLI tool + pub fn inspect_token(&self, conn: &DbConn, token: &str) -> Result<()> { + let raw = BASE64.decode(token.as_bytes())?; + let mac = match Macaroon::deserialize(&raw) { + Ok(m) => m, + Err(e) => bail!("macaroon deserialize error: {:?}", e), + }; + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + println!("current time: {}", now); + println!("domain (location): {:?}", mac.location()); + println!("signing key name (identifier): {}", mac.identifier()); + for caveat in mac.first_party_caveats() { + println!("caveat: {}", caveat.predicate()); + } + println!("verify: {:?}", self.parse_macaroon_token(conn, token, None)); + Ok(()) + } +} + +pub fn create_key() -> String { + let mut key: Vec<u8> = vec![0; 32]; + for v in key.iter_mut() { + *v = rand::random() + } + BASE64.encode(&key) +} + +pub fn revoke_tokens(conn: &DbConn, editor_id: FatCatId) -> Result<()> { + diesel::update(editor::table.filter(editor::id.eq(&editor_id.to_uuid()))) + .set(editor::auth_epoch.eq(Utc::now())) + .execute(conn)?; + Ok(()) +} + +pub fn revoke_tokens_everyone(conn: &DbConn) -> Result<()> { + diesel::update(editor::table) + .set(editor::auth_epoch.eq(Utc::now())) + .execute(conn)?; + Ok(()) +} + +// TODO: refactor out of this file? +/// Only used from CLI tool +pub fn print_editors(conn: &DbConn) -> Result<()> { + // iterate over all editors. format id, print flags, auth_epoch + let all_editors: Vec<EditorRow> = editor::table.load(conn)?; + println!("editor_id\t\t\tsuper/admin/bot\tauth_epoch\t\t\tusername\twrangler_id"); + for e in all_editors { + println!( + "{}\t{}/{}/{}\t{}\t{}\t{:?}", + FatCatId::from_uuid(&e.id).to_string(), + e.is_superuser, + e.is_admin, + e.is_bot, + e.auth_epoch, + e.username, + e.wrangler_id, + ); + } + Ok(()) +} diff --git a/rust/src/bin/fatcat-auth.rs b/rust/src/bin/fatcat-auth.rs new file mode 100644 index 00000000..addd2b66 --- /dev/null +++ b/rust/src/bin/fatcat-auth.rs @@ -0,0 +1,134 @@ +//! JSON Export Helper + +//#[macro_use] +extern crate clap; +extern crate diesel; +extern crate dotenv; +#[macro_use] +extern crate error_chain; +extern crate fatcat; +//#[macro_use] +extern crate env_logger; +extern crate log; +extern crate serde_json; +extern crate uuid; + +use clap::{App, SubCommand}; + +use diesel::prelude::*; +use fatcat::api_helpers::FatCatId; +use fatcat::errors::*; +use std::str::FromStr; +//use uuid::Uuid; + +//use error_chain::ChainedError; +//use std::io::{Stdout,StdoutLock}; +//use std::io::prelude::*; +//use std::io::{BufReader, BufWriter}; + +fn run() -> Result<()> { + let m = App::new("fatcat-auth") + .version(env!("CARGO_PKG_VERSION")) + .author("Bryan Newbold <bnewbold@archive.org>") + .about("Editor authentication admin tool") + .subcommand( + SubCommand::with_name("list-editors").about("Prints all currently registered editors"), + ) + .subcommand( + SubCommand::with_name("create-editor") + .about("Creates a new auth token (macaroon) for the given editor") + .args_from_usage( + "<username> 'username for editor' + --admin 'creates editor with admin privs' + --bot 'this editor is a bot'", + ), + ) + .subcommand( + SubCommand::with_name("create-token") + .about("Creates a new auth token (macaroon) for the given editor") + .args_from_usage( + "<editor-id> 'id of the editor (fatcatid, not username)' + --env-format 'outputs in a format that shells can source'", // TODO + ), + ) + .subcommand( + SubCommand::with_name("inspect-token") + .about("Dumps token metadata (and whether it is valid)") + .args_from_usage("<token> 'base64-encoded token (macaroon)'"), + ) + .subcommand( + SubCommand::with_name("create-key") + .about("Creates a new auth secret key (aka, root/signing key for tokens)") + .args_from_usage( + "--env-format 'outputs in a format that shells can source'", // TODO + ), + ) + .subcommand( + SubCommand::with_name("revoke-tokens") + .about("Resets auth_epoch for a single editor (invalidating all existing tokens)") + .args_from_usage("<editor-id> 'identifier (fcid) of editor'"), + ) + .subcommand( + SubCommand::with_name("revoke-tokens-everyone") + .about("Resets auth_epoch for all editors (invalidating tokens for all users!)"), + ) + .get_matches(); + + // First, the commands with no db or confectionary needed + match m.subcommand() { + ("create-key", Some(_subm)) => { + println!("{}", fatcat::auth::create_key()); + return Ok(()); + } + _ => (), + } + + // Then the ones that do + let db_conn = fatcat::database_worker_pool()? + .get() + .expect("database pool"); + let confectionary = fatcat::env_confectionary()?; + match m.subcommand() { + ("list-editors", Some(_subm)) => { + fatcat::auth::print_editors(&db_conn)?; + } + ("create-editor", Some(subm)) => { + let editor = fatcat::api_helpers::create_editor( + &db_conn, + subm.value_of("username").unwrap().to_string(), + subm.is_present("admin"), + subm.is_present("bot"), + )?; + //println!("{:?}", editor); + println!("{}", FatCatId::from_uuid(&editor.id).to_string()); + } + ("create-token", Some(subm)) => { + let editor_id = FatCatId::from_str(subm.value_of("editor-id").unwrap())?; + // check that editor exists + let _ed: fatcat::database_models::EditorRow = fatcat::database_schema::editor::table + .find(&editor_id.to_uuid()) + .get_result(&db_conn)?; + println!("{}", confectionary.create_token(editor_id, None)?); + } + ("inspect-token", Some(subm)) => { + confectionary.inspect_token(&db_conn, subm.value_of("token").unwrap())?; + } + ("revoke-tokens", Some(subm)) => { + let editor_id = FatCatId::from_str(subm.value_of("editor-id").unwrap())?; + fatcat::auth::revoke_tokens(&db_conn, editor_id)?; + println!("success!"); + } + ("revoke-tokens-everyone", Some(_subm)) => { + fatcat::auth::revoke_tokens_everyone(&db_conn)?; + println!("success!"); + } + _ => { + println!("Missing or unimplemented command!"); + println!("{}", m.usage()); + ::std::process::exit(-1); + } + } + Ok(()) +} + +quick_main!(run); diff --git a/rust/src/bin/fatcat-export.rs b/rust/src/bin/fatcat-export.rs index ec66ed4c..e1b930fc 100644 --- a/rust/src/bin/fatcat-export.rs +++ b/rust/src/bin/fatcat-export.rs @@ -17,15 +17,10 @@ extern crate serde_json; extern crate uuid; use clap::{App, Arg}; -use dotenv::dotenv; -use std::env; -use diesel::prelude::*; -use diesel::r2d2::ConnectionManager; use fatcat::api_entity_crud::*; use fatcat::api_helpers::*; use fatcat::errors::*; -use fatcat::ConnectionPool; use fatcat_api_spec::models::*; use std::str::FromStr; use uuid::Uuid; @@ -59,17 +54,6 @@ struct IdentRow { redirect_id: Option<FatCatId>, } -/// Instantiate a new API server with a pooled database connection -pub fn database_worker_pool() -> Result<ConnectionPool> { - dotenv().ok(); - let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); - let manager = ConnectionManager::<PgConnection>::new(database_url); - let pool = diesel::r2d2::Pool::builder() - .build(manager) - .expect("Failed to create database pool."); - Ok(pool) -} - macro_rules! generic_loop_work { ($fn_name:ident, $entity_model:ident) => { fn $fn_name( @@ -183,7 +167,7 @@ pub fn do_export( entity_type: ExportEntityType, redirects: bool, ) -> Result<()> { - let db_pool = database_worker_pool()?; + let db_pool = fatcat::database_worker_pool()?; let buf_input = BufReader::new(std::io::stdin()); let (row_sender, row_receiver) = channel::bounded(CHANNEL_BUFFER_LEN); let (output_sender, output_receiver) = channel::bounded(CHANNEL_BUFFER_LEN); diff --git a/rust/src/bin/fatcatd.rs b/rust/src/bin/fatcatd.rs index 57b6a3da..682f5038 100644 --- a/rust/src/bin/fatcatd.rs +++ b/rust/src/bin/fatcatd.rs @@ -20,9 +20,6 @@ use iron::modifiers::RedirectRaw; use iron::{status, Chain, Iron, IronResult, Request, Response}; use iron_slog::{DefaultLogFormatter, LoggerMiddleware}; use slog::{Drain, Logger}; -//use dotenv::dotenv; -//use std::env; -//use swagger::auth::AllowAllMiddleware; /// Create custom server, wire it to the autogenerated router, /// and pass it to the web server. @@ -42,6 +39,19 @@ fn main() { let formatter = DefaultLogFormatter; let server = fatcat::server().unwrap(); + info!( + logger, + "using primary auth key: {}", server.auth_confectionary.identifier, + ); + info!( + logger, + "all auth keys: {:?}", + server + .auth_confectionary + .root_keys + .keys() + .collect::<Vec<&String>>(), + ); let mut router = fatcat_api_spec::router(server); router.get("/", root_handler, "root-redirect"); @@ -78,11 +88,9 @@ fn main() { let mut chain = Chain::new(LoggerMiddleware::new(router, logger, formatter)); - // Auth stuff unused for now - //chain.link_before(fatcat_api_spec::server::ExtractAuthData); - // add authentication middlewares into the chain here - // for the purpose of this example, pretend we have authenticated a user - //chain.link_before(AllowAllMiddleware::new("cosmo")); + // authentication + chain.link_before(fatcat_api_spec::server::ExtractAuthData); + chain.link_before(fatcat::auth::MacaroonAuthMiddleware::new()); chain.link_after(fatcat::XClacksOverheadMiddleware); diff --git a/rust/src/database_models.rs b/rust/src/database_models.rs index fc5fc896..59953f6b 100644 --- a/rust/src/database_models.rs +++ b/rust/src/database_models.rs @@ -4,7 +4,7 @@ use api_helpers::uuid2fcid; use chrono; use database_schema::*; use errors::*; -use fatcat_api_spec::models::{ChangelogEntry, Editgroup, EntityEdit}; +use fatcat_api_spec::models::{ChangelogEntry, Editgroup, Editor, EntityEdit}; use serde_json; use uuid::Uuid; @@ -559,12 +559,12 @@ pub struct EditgroupRow { } impl EditgroupRow { - /// Returns an Edigroup API model *without* the entity edits actually populated. Useful for, + /// Returns an Editgroup API model *without* the entity edits actually populated. Useful for, /// eg, entity history queries (where we already have the entity edit we want) pub fn into_model_partial(self) -> Editgroup { Editgroup { editgroup_id: Some(uuid2fcid(&self.id)), - editor_id: uuid2fcid(&self.editor_id), + editor_id: Some(uuid2fcid(&self.editor_id)), description: self.description, extra: self.extra_json, edits: None, @@ -572,16 +572,44 @@ impl EditgroupRow { } } -#[derive(Debug, Queryable, Identifiable, Associations, AsChangeset)] +#[derive(Debug, Clone, Queryable, Identifiable, Associations, AsChangeset)] #[table_name = "editor"] pub struct EditorRow { pub id: Uuid, pub username: String, + pub is_superuser: bool, pub is_admin: bool, + pub is_bot: bool, + pub is_active: bool, pub registered: chrono::NaiveDateTime, + pub auth_epoch: chrono::NaiveDateTime, + pub wrangler_id: Option<Uuid>, pub active_editgroup_id: Option<Uuid>, } +impl EditorRow { + pub fn into_model(self) -> Editor { + Editor { + editor_id: Some(uuid2fcid(&self.id)), + username: self.username, + is_admin: Some(self.is_admin), + is_bot: Some(self.is_bot), + is_active: Some(self.is_active), + } + } +} + +#[derive(Debug, Clone, Queryable, Associations, AsChangeset)] +#[table_name = "auth_oidc"] +pub struct AuthOidcRow { + pub id: i64, + pub created: chrono::NaiveDateTime, + pub editor_id: Uuid, + pub provider: String, + pub oidc_iss: String, + pub oidc_sub: String, +} + #[derive(Debug, Queryable, Identifiable, Associations, AsChangeset)] #[table_name = "changelog"] pub struct ChangelogRow { diff --git a/rust/src/database_schema.rs b/rust/src/database_schema.rs index 2777696d..0c553b40 100644 --- a/rust/src/database_schema.rs +++ b/rust/src/database_schema.rs @@ -6,6 +6,17 @@ table! { } table! { + auth_oidc (id) { + id -> Int8, + created -> Timestamptz, + editor_id -> Uuid, + provider -> Text, + oidc_iss -> Text, + oidc_sub -> Text, + } +} + +table! { changelog (id) { id -> Int8, editgroup_id -> Uuid, @@ -96,8 +107,13 @@ table! { editor (id) { id -> Uuid, username -> Text, + is_superuser -> Bool, is_admin -> Bool, + is_bot -> Bool, + is_active -> Bool, registered -> Timestamptz, + auth_epoch -> Timestamptz, + wrangler_id -> Nullable<Uuid>, active_editgroup_id -> Nullable<Uuid>, } } @@ -384,6 +400,7 @@ table! { } } +joinable!(auth_oidc -> editor (editor_id)); joinable!(changelog -> editgroup (editgroup_id)); joinable!(container_edit -> editgroup (editgroup_id)); joinable!(container_ident -> container_rev (rev_id)); @@ -421,6 +438,7 @@ joinable!(work_ident -> work_rev (rev_id)); allow_tables_to_appear_in_same_query!( abstracts, + auth_oidc, changelog, container_edit, container_ident, diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 0bed3471..b3e6c813 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -11,11 +11,10 @@ extern crate futures; extern crate uuid; #[macro_use] extern crate hyper; -//extern crate swagger; +extern crate swagger; #[macro_use] extern crate error_chain; extern crate iron; -#[macro_use] extern crate serde_json; #[macro_use] extern crate log; @@ -23,12 +22,14 @@ extern crate data_encoding; extern crate regex; #[macro_use] extern crate lazy_static; +extern crate macaroon; extern crate sha1; pub mod api_entity_crud; pub mod api_helpers; pub mod api_server; pub mod api_wrappers; +pub mod auth; pub mod database_models; pub mod database_schema; @@ -41,6 +42,8 @@ pub mod errors { Uuid(::uuid::ParseError); Io(::std::io::Error) #[cfg(unix)]; Serde(::serde_json::Error); + Utf8Decode(::std::string::FromUtf8Error); + StringDecode(::data_encoding::DecodeError); } errors { InvalidFatcatId(id: String) { @@ -71,6 +74,14 @@ pub mod errors { description("Invalid Entity State Transform") display("tried to mutate an entity which was not in an appropriate state: {}", message) } + InvalidCredentials(message: String) { + description("auth token was missing, expired, revoked, or corrupt") + display("auth token was missing, expired, revoked, or corrupt: {}", message) + } + InsufficientPrivileges(message: String) { + description("editor account doesn't have authorization") + display("editor account doesn't have authorization: {}", message) + } OtherBadRequest(message: String) { description("catch-all error for bad or unallowed requests") display("broke a constraint or made an otherwise invalid request: {}", message) @@ -83,8 +94,8 @@ pub mod errors { pub use errors::*; pub use self::errors::*; +use auth::AuthConfectionary; use diesel::pg::PgConnection; -use diesel::prelude::*; use diesel::r2d2::ConnectionManager; use dotenv::dotenv; use iron::middleware::AfterMiddleware; @@ -96,14 +107,38 @@ embed_migrations!("../migrations/"); pub type ConnectionPool = diesel::r2d2::Pool<ConnectionManager<diesel::pg::PgConnection>>; -/// Establish a direct database connection. Not currently used, but could be helpful for -/// single-threaded tests or utilities. -pub fn establish_connection() -> PgConnection { +/// Instantiate a new API server with a pooled database connection +pub fn database_worker_pool() -> Result<ConnectionPool> { dotenv().ok(); - let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); - PgConnection::establish(&database_url) - .unwrap_or_else(|_| panic!("Error connecting to {}", database_url)) + let manager = ConnectionManager::<PgConnection>::new(database_url); + let pool = diesel::r2d2::Pool::builder() + .build(manager) + .expect("Failed to create database pool."); + Ok(pool) +} + +pub fn env_confectionary() -> Result<AuthConfectionary> { + let auth_location = env::var("AUTH_LOCATION").expect("AUTH_LOCATION must be set"); + let auth_key = env::var("AUTH_SECRET_KEY").expect("AUTH_SECRET_KEY must be set"); + let auth_key_ident = env::var("AUTH_KEY_IDENT").expect("AUTH_KEY_IDENT must be set"); + info!("Loaded primary auth key: {}", auth_key_ident); + let mut confectionary = AuthConfectionary::new(auth_location, auth_key_ident, auth_key)?; + match env::var("AUTH_ALT_KEYS") { + Ok(var) => { + for pair in var.split(",") { + let pair: Vec<&str> = pair.split(":").collect(); + if pair.len() != 2 { + println!("{:#?}", pair); + bail!("couldn't parse keypair from AUTH_ALT_KEYS (expected 'ident:key' pairs separated by commas)"); + } + info!("Loading alt auth key: {}", pair[0]); + confectionary.add_keypair(pair[0].to_string(), pair[1].to_string())?; + } + } + Err(_) => (), + } + Ok(confectionary) } /// Instantiate a new API server with a pooled database connection @@ -114,7 +149,11 @@ pub fn server() -> Result<api_server::Server> { let pool = diesel::r2d2::Pool::builder() .build(manager) .expect("Failed to create database pool."); - Ok(api_server::Server { db_pool: pool }) + let confectionary = env_confectionary()?; + Ok(api_server::Server { + db_pool: pool, + auth_confectionary: confectionary, + }) } pub fn test_server() -> Result<api_server::Server> { @@ -122,7 +161,8 @@ pub fn test_server() -> Result<api_server::Server> { let database_url = env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL must be set"); env::set_var("DATABASE_URL", database_url); - let server = server()?; + let mut server = server()?; + server.auth_confectionary = AuthConfectionary::new_dummy(); let conn = server.db_pool.get().expect("db_pool error"); // run migrations; revert latest (dummy data); re-run latest |