aboutsummaryrefslogtreecommitdiffstats
path: root/rust/src
diff options
context:
space:
mode:
Diffstat (limited to 'rust/src')
-rw-r--r--rust/src/api_helpers.rs76
-rw-r--r--rust/src/api_server.rs66
-rw-r--r--rust/src/api_wrappers.rs352
-rw-r--r--rust/src/auth.rs470
-rw-r--r--rust/src/bin/fatcat-auth.rs134
-rw-r--r--rust/src/bin/fatcat-export.rs18
-rw-r--r--rust/src/bin/fatcatd.rs24
-rw-r--r--rust/src/database_models.rs36
-rw-r--r--rust/src/database_schema.rs18
-rw-r--r--rust/src/lib.rs62
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