Skip to content

Commit

Permalink
feat: Case-insensitive username and email fields
Browse files Browse the repository at this point in the history
Problem
-------
It's common for typos to occur when entering usernames and emails that
result in random incorrect capitalization. Additionally, it's both a
security vulnerability and a usability issue if multiple users can
have the same username/email just with different capitalization.

Solution
--------
- Add a case-insensitive collation (we only officially support this on
  Postgres
- Assign the Username and Email columns of the User table to use the
  case-insensitive collation.
  • Loading branch information
spencewenski committed Oct 22, 2024
1 parent 81f6b13 commit 383c4c3
Show file tree
Hide file tree
Showing 21 changed files with 485 additions and 28 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//! Migration to create a case-insensitive collation.
//!
//! See: <https://www.postgresql.org/docs/current/collation.html#COLLATION-NONDETERMINISTIC>
//!
//! Note: Currently only supports Postgres. If another DB is used, will do nothing.
use crate::migration::collation::{
exec_create_case_insensitive_collation, exec_drop_case_insensitive_collation,
};
use async_trait::async_trait;
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
exec_create_case_insensitive_collation(manager).await
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
exec_drop_case_insensitive_collation(manager).await
}
}
173 changes: 173 additions & 0 deletions src/migration/collation/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
//! Utilities and migrations related to collations.
pub mod m20241022_065427_case_insensitive_collation;

use sea_orm::{DbBackend, Statement};
use sea_orm_migration::prelude::*;

/// Collations available from Roadster.
#[derive(DeriveIden)]
#[non_exhaustive]
pub enum Collation {
/// The `default` collation. This comes included in Postgres.
///
/// Note: This iden needs to be surrounded in quotes, at least in Postgres.
Default,
/// A case-insensitive collation.
CaseInsensitive,
}

/// Wrapper around [`create_case_insensitive_collation`] to execute the returned [`Statement`], if
/// present.
///
/// # Examples
/// ```rust
/// use roadster::migration::collation::exec_create_case_insensitive_collation;
/// use sea_orm_migration::prelude::*;
///
/// #[derive(DeriveMigrationName)]
/// pub struct Migration;
///
/// #[async_trait::async_trait]
/// impl MigrationTrait for Migration {
/// async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
/// exec_create_case_insensitive_collation(manager).await
/// }
/// #
/// # async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
/// # todo!()
/// # }
/// }
/// ```
pub async fn exec_create_case_insensitive_collation(
manager: &SchemaManager<'_>,
) -> Result<(), DbErr> {
let statement = create_case_insensitive_collation(manager);
if let Some(statement) = statement {
manager.get_connection().execute(statement).await?;
}

Ok(())
}

/// Create a case-insensitive collation.
///
/// See: <https://www.postgresql.org/docs/current/collation.html#COLLATION-NONDETERMINISTIC>
///
/// Note: Currently only supports Postgres. If another DB is used, will return [`None`].
pub fn create_case_insensitive_collation(manager: &SchemaManager<'_>) -> Option<Statement> {
let backend = manager.get_database_backend();
create_case_insensitive_collation_for_db_backend(backend)
}

fn create_case_insensitive_collation_for_db_backend(backend: DbBackend) -> Option<Statement> {
if let DbBackend::Postgres = backend {
Some(Statement::from_string(
backend,
format!(
r#"CREATE COLLATION IF NOT EXISTS {} (
provider = icu,
locale = 'und-u-ks-level2',
deterministic = false
);
"#,
Collation::CaseInsensitive.to_string()
),
))
} else {
None
}
}

/// Wrapper around [`drop_case_insensitive_collation`] to execute the returned [`Statement`], if
/// present.
///
/// # Examples
/// ```rust
/// use roadster::migration::collation::exec_drop_case_insensitive_collation;
/// use sea_orm_migration::prelude::*;
///
/// #[derive(DeriveMigrationName)]
/// pub struct Migration;
///
/// #[async_trait::async_trait]
/// impl MigrationTrait for Migration {
/// # async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
/// # todo!()
/// # }
/// #
/// async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
/// exec_drop_case_insensitive_collation(manager).await
/// }
/// }
/// ```
pub async fn exec_drop_case_insensitive_collation(
manager: &SchemaManager<'_>,
) -> Result<(), DbErr> {
let statement = drop_case_insensitive_collation(manager);
if let Some(statement) = statement {
manager.get_connection().execute(statement).await?;
}

Ok(())
}

/// Drop the case-insensitive collation that was previously created by [`create_case_insensitive_collation`].
///
/// Note: Currently only supports Postgres. If another DB is used, will return [None].
pub fn drop_case_insensitive_collation(manager: &SchemaManager<'_>) -> Option<Statement> {
let backend = manager.get_database_backend();
drop_case_insensitive_collation_for_db_backend(backend)
}

fn drop_case_insensitive_collation_for_db_backend(backend: DbBackend) -> Option<Statement> {
if let DbBackend::Postgres = backend {
Some(Statement::from_string(
backend,
format!(
"DROP COLLATION IF EXISTS {};",
Collation::CaseInsensitive.to_string()
),
))
} else {
None
}
}

#[cfg(test)]
mod tests {
use crate::testing::snapshot::TestCase;
use insta::assert_debug_snapshot;
use rstest::{fixture, rstest};
use sea_orm::DbBackend;

#[fixture]
fn case() -> TestCase {
Default::default()
}

#[rstest]
#[case(DbBackend::Postgres)]
#[case(DbBackend::MySql)]
#[case(DbBackend::Sqlite)]
#[cfg_attr(coverage_nightly, coverage(off))]
fn create_case_insensitive_collation_for_db_backend(
_case: TestCase,
#[case] backend: DbBackend,
) {
let statement = super::create_case_insensitive_collation_for_db_backend(backend);

assert_debug_snapshot!(statement);
}

#[rstest]
#[case(DbBackend::Postgres)]
#[case(DbBackend::MySql)]
#[case(DbBackend::Sqlite)]
#[cfg_attr(coverage_nightly, coverage(off))]
fn drop_case_insensitive_collation_for_db_backend(_case: TestCase, #[case] backend: DbBackend) {
let statement = super::drop_case_insensitive_collation_for_db_backend(backend);

assert_debug_snapshot!(statement);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
source: src/migration/collation/mod.rs
expression: statement
---
Some(
Statement {
sql: "CREATE COLLATION IF NOT EXISTS case_insensitive (\nprovider = icu,\nlocale = 'und-u-ks-level2',\ndeterministic = false\n);\n",
values: None,
db_backend: Postgres,
},
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/migration/collation/mod.rs
expression: statement
---
None
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/migration/collation/mod.rs
expression: statement
---
None
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
source: src/migration/collation/mod.rs
expression: statement
---
Some(
Statement {
sql: "DROP COLLATION IF EXISTS case_insensitive;",
values: None,
db_backend: Postgres,
},
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/migration/collation/mod.rs
expression: statement
---
None
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/migration/collation/mod.rs
expression: statement
---
None
1 change: 1 addition & 0 deletions src/migration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! Additionally, some utilities are provided to create some common column types.
pub mod check;
pub mod collation;
pub mod schema;
pub mod timestamp;
pub mod user;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Migration to create a SQL function to update the [Timestamps::UpdatedAt] column for a row
//! Migration to create a SQL function to update the [`Timestamps::UpdatedAt`] column for a row
//! with the current timestamp.
//!
//! Note: Currently only supports Postgres. If another DB is used, will do nothing.
Expand Down
Loading

0 comments on commit 383c4c3

Please sign in to comment.