summaryrefslogtreecommitdiff
path: root/src/login
diff options
context:
space:
mode:
Diffstat (limited to 'src/login')
-rw-r--r--src/login/app.rs140
-rw-r--r--src/login/broadcaster.rs3
-rw-r--r--src/login/extract.rs181
-rw-r--r--src/login/id.rs24
-rw-r--r--src/login/mod.rs23
-rw-r--r--src/login/password.rs58
-rw-r--r--src/login/repo.rs50
-rw-r--r--src/login/repo/auth.rs53
-rw-r--r--src/login/repo/mod.rs1
-rw-r--r--src/login/routes.rs28
-rw-r--r--src/login/routes/test/boot.rs7
-rw-r--r--src/login/routes/test/login.rs13
-rw-r--r--src/login/routes/test/logout.rs7
-rw-r--r--src/login/types.rs12
14 files changed, 196 insertions, 404 deletions
diff --git a/src/login/app.rs b/src/login/app.rs
index 182c62c..15adb31 100644
--- a/src/login/app.rs
+++ b/src/login/app.rs
@@ -1,57 +1,25 @@
-use chrono::TimeDelta;
-use futures::{
- future,
- stream::{self, StreamExt as _},
- Stream,
-};
use sqlx::sqlite::SqlitePool;
-use super::{broadcaster::Broadcaster, extract::IdentitySecret, repo::auth::Provider as _, types};
-use crate::{
- clock::DateTime,
- password::Password,
- repo::{
- error::NotFound as _,
- login::{Login, Provider as _},
- token::{self, Provider as _},
- },
-};
+use crate::event::{repo::Provider as _, Sequence};
+
+#[cfg(test)]
+use super::{repo::Provider as _, Login, Password};
pub struct Logins<'a> {
db: &'a SqlitePool,
- logins: &'a Broadcaster,
}
impl<'a> Logins<'a> {
- pub const fn new(db: &'a SqlitePool, logins: &'a Broadcaster) -> Self {
- Self { db, logins }
+ pub const fn new(db: &'a SqlitePool) -> Self {
+ Self { db }
}
- pub async fn login(
- &self,
- name: &str,
- password: &Password,
- login_at: &DateTime,
- ) -> Result<IdentitySecret, LoginError> {
+ pub async fn boot_point(&self) -> Result<Sequence, sqlx::Error> {
let mut tx = self.db.begin().await?;
-
- let login = if let Some((login, stored_hash)) = tx.auth().for_name(name).await? {
- if stored_hash.verify(password)? {
- // Password verified; use the login.
- login
- } else {
- // Password NOT verified.
- return Err(LoginError::Rejected);
- }
- } else {
- let password_hash = password.hash()?;
- tx.logins().create(name, &password_hash).await?
- };
-
- let token = tx.tokens().issue(&login, login_at).await?;
+ let sequence = tx.sequence().current().await?;
tx.commit().await?;
- Ok(token)
+ Ok(sequence)
}
#[cfg(test)]
@@ -64,82 +32,6 @@ impl<'a> Logins<'a> {
Ok(login)
}
-
- pub async fn validate(
- &self,
- secret: &IdentitySecret,
- used_at: &DateTime,
- ) -> Result<(token::Id, Login), ValidateError> {
- let mut tx = self.db.begin().await?;
- let login = tx
- .tokens()
- .validate(secret, used_at)
- .await
- .not_found(|| ValidateError::InvalidToken)?;
- tx.commit().await?;
-
- Ok(login)
- }
-
- pub fn limit_stream<E>(
- &self,
- token: token::Id,
- events: impl Stream<Item = E> + std::fmt::Debug,
- ) -> impl Stream<Item = E> + std::fmt::Debug
- where
- E: std::fmt::Debug,
- {
- let token_events = self
- .logins
- .subscribe()
- .filter(move |event| future::ready(event.token == token))
- .map(|_| GuardedEvent::TokenRevoked);
-
- let events = events.map(|event| GuardedEvent::Event(event));
-
- stream::select(token_events, events).scan((), |(), event| {
- future::ready(match event {
- GuardedEvent::Event(event) => Some(event),
- GuardedEvent::TokenRevoked => None,
- })
- })
- }
-
- pub async fn expire(&self, relative_to: &DateTime) -> Result<(), sqlx::Error> {
- // Somewhat arbitrarily, expire after 7 days.
- let expire_at = relative_to.to_owned() - TimeDelta::days(7);
-
- let mut tx = self.db.begin().await?;
- let tokens = tx.tokens().expire(&expire_at).await?;
- tx.commit().await?;
-
- for event in tokens.into_iter().map(types::TokenRevoked::from) {
- self.logins.broadcast(&event);
- }
-
- Ok(())
- }
-
- pub async fn logout(&self, token: &token::Id) -> Result<(), ValidateError> {
- let mut tx = self.db.begin().await?;
- tx.tokens().revoke(token).await?;
- tx.commit().await?;
-
- self.logins
- .broadcast(&types::TokenRevoked::from(token.clone()));
-
- Ok(())
- }
-}
-
-#[derive(Debug, thiserror::Error)]
-pub enum LoginError {
- #[error("invalid login")]
- Rejected,
- #[error(transparent)]
- DatabaseError(#[from] sqlx::Error),
- #[error(transparent)]
- PasswordHashError(#[from] password_hash::Error),
}
#[cfg(test)]
@@ -149,17 +41,3 @@ pub enum CreateError {
DatabaseError(#[from] sqlx::Error),
PasswordHashError(#[from] password_hash::Error),
}
-
-#[derive(Debug, thiserror::Error)]
-pub enum ValidateError {
- #[error("invalid token")]
- InvalidToken,
- #[error(transparent)]
- DatabaseError(#[from] sqlx::Error),
-}
-
-#[derive(Debug)]
-enum GuardedEvent<E> {
- TokenRevoked,
- Event(E),
-}
diff --git a/src/login/broadcaster.rs b/src/login/broadcaster.rs
deleted file mode 100644
index 8e1fb3a..0000000
--- a/src/login/broadcaster.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-use crate::{broadcast, login::types};
-
-pub type Broadcaster = broadcast::Broadcaster<types::TokenRevoked>;
diff --git a/src/login/extract.rs b/src/login/extract.rs
index b585565..c2d97f2 100644
--- a/src/login/extract.rs
+++ b/src/login/extract.rs
@@ -1,182 +1,15 @@
-use std::fmt;
+use axum::{extract::FromRequestParts, http::request::Parts};
-use axum::{
- extract::{FromRequestParts, State},
- http::{request::Parts, StatusCode},
- response::{IntoResponse, IntoResponseParts, Response, ResponseParts},
-};
-use axum_extra::extract::cookie::{Cookie, CookieJar};
-
-use crate::{
- app::App,
- clock::RequestedAt,
- error::Internal,
- login::app::ValidateError,
- repo::{login::Login, token},
-};
-
-// The usage pattern here - receive the extractor as an argument, return it in
-// the response - is heavily modelled after CookieJar's own intended usage.
-#[derive(Clone)]
-pub struct IdentityToken {
- cookies: CookieJar,
-}
-
-impl fmt::Debug for IdentityToken {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- f.debug_struct("IdentityToken")
- .field(
- "identity",
- &self.cookies.get(IDENTITY_COOKIE).map(|_| "********"),
- )
- .finish()
- }
-}
-
-impl IdentityToken {
- // Creates a new, unpopulated identity token store.
- #[cfg(test)]
- pub fn new() -> Self {
- Self {
- cookies: CookieJar::new(),
- }
- }
-
- // Get the identity secret sent in the request, if any. If the identity
- // was not sent, or if it has previously been [clear]ed, then this will
- // return [None]. If the identity has previously been [set], then this
- // will return that secret, regardless of what the request originally
- // included.
- pub fn secret(&self) -> Option<IdentitySecret> {
- self.cookies
- .get(IDENTITY_COOKIE)
- .map(Cookie::value)
- .map(IdentitySecret::from)
- }
-
- // Positively set the identity secret, and ensure that it will be sent
- // back to the client when this extractor is included in a response.
- pub fn set(self, secret: impl Into<IdentitySecret>) -> Self {
- let IdentitySecret(secret) = secret.into();
- let identity_cookie = Cookie::build((IDENTITY_COOKIE, secret))
- .http_only(true)
- .path("/api/")
- .permanent()
- .build();
-
- Self {
- cookies: self.cookies.add(identity_cookie),
- }
- }
-
- // Remove the identity secret and ensure that it will be cleared when this
- // extractor is included in a response.
- pub fn clear(self) -> Self {
- Self {
- cookies: self.cookies.remove(IDENTITY_COOKIE),
- }
- }
-}
-
-const IDENTITY_COOKIE: &str = "identity";
-
-#[async_trait::async_trait]
-impl<S> FromRequestParts<S> for IdentityToken
-where
- S: Send + Sync,
-{
- type Rejection = <CookieJar as FromRequestParts<S>>::Rejection;
-
- async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
- let cookies = CookieJar::from_request_parts(parts, state).await?;
- Ok(Self { cookies })
- }
-}
-
-impl IntoResponseParts for IdentityToken {
- type Error = <CookieJar as IntoResponseParts>::Error;
-
- fn into_response_parts(self, res: ResponseParts) -> Result<ResponseParts, Self::Error> {
- let Self { cookies } = self;
- cookies.into_response_parts(res)
- }
-}
-
-#[derive(sqlx::Type)]
-#[sqlx(transparent)]
-pub struct IdentitySecret(String);
-
-impl fmt::Debug for IdentitySecret {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- f.debug_tuple("IdentityToken").field(&"********").finish()
- }
-}
-
-impl<S> From<S> for IdentitySecret
-where
- S: Into<String>,
-{
- fn from(value: S) -> Self {
- Self(value.into())
- }
-}
-
-#[derive(Clone, Debug)]
-pub struct Identity {
- pub token: token::Id,
- pub login: Login,
-}
+use super::Login;
+use crate::{app::App, token::extract::Identity};
#[async_trait::async_trait]
-impl FromRequestParts<App> for Identity {
- type Rejection = LoginError<Internal>;
+impl FromRequestParts<App> for Login {
+ type Rejection = <Identity as FromRequestParts<App>>::Rejection;
async fn from_request_parts(parts: &mut Parts, state: &App) -> Result<Self, Self::Rejection> {
- // After Rust 1.82 (and #[feature(min_exhaustive_patterns)] lands on
- // stable), the following can be replaced:
- //
- // ```
- // let Ok(identity_token) = IdentityToken::from_request_parts(
- // parts,
- // state,
- // ).await;
- // ```
- let identity_token = IdentityToken::from_request_parts(parts, state).await?;
- let RequestedAt(used_at) = RequestedAt::from_request_parts(parts, state).await?;
-
- let secret = identity_token.secret().ok_or(LoginError::Unauthorized)?;
-
- let app = State::<App>::from_request_parts(parts, state).await?;
- match app.logins().validate(&secret, &used_at).await {
- Ok((token, login)) => Ok(Identity { token, login }),
- Err(ValidateError::InvalidToken) => Err(LoginError::Unauthorized),
- Err(other) => Err(other.into()),
- }
- }
-}
-
-pub enum LoginError<E> {
- Failure(E),
- Unauthorized,
-}
-
-impl<E> IntoResponse for LoginError<E>
-where
- E: IntoResponse,
-{
- fn into_response(self) -> Response {
- match self {
- Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized").into_response(),
- Self::Failure(e) => e.into_response(),
- }
- }
-}
+ let identity = Identity::from_request_parts(parts, state).await?;
-impl<E> From<E> for LoginError<Internal>
-where
- E: Into<Internal>,
-{
- fn from(err: E) -> Self {
- Self::Failure(err.into())
+ Ok(identity.login)
}
}
diff --git a/src/login/id.rs b/src/login/id.rs
new file mode 100644
index 0000000..c46d697
--- /dev/null
+++ b/src/login/id.rs
@@ -0,0 +1,24 @@
+use crate::id::Id as BaseId;
+
+// Stable identifier for a [Login]. Prefixed with `L`.
+#[derive(Clone, Debug, Eq, PartialEq, sqlx::Type, serde::Serialize)]
+#[sqlx(transparent)]
+pub struct Id(BaseId);
+
+impl From<BaseId> for Id {
+ fn from(id: BaseId) -> Self {
+ Self(id)
+ }
+}
+
+impl Id {
+ pub fn generate() -> Self {
+ BaseId::generate("L")
+ }
+}
+
+impl std::fmt::Display for Id {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ self.0.fmt(f)
+ }
+}
diff --git a/src/login/mod.rs b/src/login/mod.rs
index 6ae82ac..65e3ada 100644
--- a/src/login/mod.rs
+++ b/src/login/mod.rs
@@ -1,8 +1,21 @@
-pub use self::routes::router;
-
pub mod app;
-pub mod broadcaster;
pub mod extract;
-mod repo;
+mod id;
+pub mod password;
+pub mod repo;
mod routes;
-pub mod types;
+
+pub use self::{id::Id, password::Password, routes::router};
+
+// This also implements FromRequestParts (see `./extract.rs`). As a result, it
+// can be used as an extractor for endpoints that want to require login, or for
+// endpoints that need to behave differently depending on whether the client is
+// or is not logged in.
+#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
+pub struct Login {
+ pub id: Id,
+ pub name: String,
+ // The omission of the hashed password is deliberate, to minimize the
+ // chance that it ends up tangled up in debug output or in some other chunk
+ // of logic elsewhere.
+}
diff --git a/src/login/password.rs b/src/login/password.rs
new file mode 100644
index 0000000..da3930f
--- /dev/null
+++ b/src/login/password.rs
@@ -0,0 +1,58 @@
+use std::fmt;
+
+use argon2::Argon2;
+use password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
+use rand_core::OsRng;
+
+#[derive(Debug, sqlx::Type)]
+#[sqlx(transparent)]
+pub struct StoredHash(String);
+
+impl StoredHash {
+ pub fn verify(&self, password: &Password) -> Result<bool, password_hash::Error> {
+ let hash = PasswordHash::new(&self.0)?;
+
+ match Argon2::default().verify_password(password.as_bytes(), &hash) {
+ // Successful authentication, not an error
+ Ok(()) => Ok(true),
+ // Unsuccessful authentication, also not an error
+ Err(password_hash::errors::Error::Password) => Ok(false),
+ // Password validation failed for some other reason, treat as an error
+ Err(err) => Err(err),
+ }
+ }
+}
+
+#[derive(serde::Deserialize)]
+#[serde(transparent)]
+pub struct Password(String);
+
+impl Password {
+ pub fn hash(&self) -> Result<StoredHash, password_hash::Error> {
+ let Self(password) = self;
+ let salt = SaltString::generate(&mut OsRng);
+ let argon2 = Argon2::default();
+ let hash = argon2
+ .hash_password(password.as_bytes(), &salt)?
+ .to_string();
+ Ok(StoredHash(hash))
+ }
+
+ fn as_bytes(&self) -> &[u8] {
+ let Self(value) = self;
+ value.as_bytes()
+ }
+}
+
+impl fmt::Debug for Password {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_tuple("Password").field(&"********").finish()
+ }
+}
+
+#[cfg(test)]
+impl From<String> for Password {
+ fn from(password: String) -> Self {
+ Self(password)
+ }
+}
diff --git a/src/login/repo.rs b/src/login/repo.rs
new file mode 100644
index 0000000..d1a02c4
--- /dev/null
+++ b/src/login/repo.rs
@@ -0,0 +1,50 @@
+use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction};
+
+use crate::login::{password::StoredHash, Id, Login};
+
+pub trait Provider {
+ fn logins(&mut self) -> Logins;
+}
+
+impl<'c> Provider for Transaction<'c, Sqlite> {
+ fn logins(&mut self) -> Logins {
+ Logins(self)
+ }
+}
+
+pub struct Logins<'t>(&'t mut SqliteConnection);
+
+impl<'c> Logins<'c> {
+ pub async fn create(
+ &mut self,
+ name: &str,
+ password_hash: &StoredHash,
+ ) -> Result<Login, sqlx::Error> {
+ let id = Id::generate();
+
+ let login = sqlx::query_as!(
+ Login,
+ r#"
+ insert or fail
+ into login (id, name, password_hash)
+ values ($1, $2, $3)
+ returning
+ id as "id: Id",
+ name
+ "#,
+ id,
+ name,
+ password_hash,
+ )
+ .fetch_one(&mut *self.0)
+ .await?;
+
+ Ok(login)
+ }
+}
+
+impl<'t> From<&'t mut SqliteConnection> for Logins<'t> {
+ fn from(tx: &'t mut SqliteConnection) -> Self {
+ Self(tx)
+ }
+}
diff --git a/src/login/repo/auth.rs b/src/login/repo/auth.rs
deleted file mode 100644
index 3033c8f..0000000
--- a/src/login/repo/auth.rs
+++ /dev/null
@@ -1,53 +0,0 @@
-use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction};
-
-use crate::{
- password::StoredHash,
- repo::login::{self, Login},
-};
-
-pub trait Provider {
- fn auth(&mut self) -> Auth;
-}
-
-impl<'c> Provider for Transaction<'c, Sqlite> {
- fn auth(&mut self) -> Auth {
- Auth(self)
- }
-}
-
-pub struct Auth<'t>(&'t mut SqliteConnection);
-
-impl<'t> Auth<'t> {
- // Retrieves a login by name, plus its stored password hash for
- // verification. If there's no login with the requested name, this will
- // return [None].
- pub async fn for_name(
- &mut self,
- name: &str,
- ) -> Result<Option<(Login, StoredHash)>, sqlx::Error> {
- let found = sqlx::query!(
- r#"
- select
- id as "id: login::Id",
- name,
- password_hash as "password_hash: StoredHash"
- from login
- where name = $1
- "#,
- name,
- )
- .map(|rec| {
- (
- Login {
- id: rec.id,
- name: rec.name,
- },
- rec.password_hash,
- )
- })
- .fetch_optional(&mut *self.0)
- .await?;
-
- Ok(found)
- }
-}
diff --git a/src/login/repo/mod.rs b/src/login/repo/mod.rs
deleted file mode 100644
index 0e4a05d..0000000
--- a/src/login/repo/mod.rs
+++ /dev/null
@@ -1 +0,0 @@
-pub mod auth;
diff --git a/src/login/routes.rs b/src/login/routes.rs
index 8d9e938..0874cc3 100644
--- a/src/login/routes.rs
+++ b/src/login/routes.rs
@@ -7,11 +7,13 @@ use axum::{
};
use crate::{
- app::App, clock::RequestedAt, error::Internal, password::Password, repo::login::Login,
+ app::App,
+ clock::RequestedAt,
+ error::{Internal, Unauthorized},
+ login::{Login, Password},
+ token::{app, extract::IdentityToken},
};
-use super::{app, extract::IdentityToken};
-
#[cfg(test)]
mod test;
@@ -22,13 +24,18 @@ pub fn router() -> Router<App> {
.route("/api/auth/logout", post(on_logout))
}
-async fn boot(login: Login) -> Boot {
- Boot { login }
+async fn boot(State(app): State<App>, login: Login) -> Result<Boot, Internal> {
+ let resume_point = app.logins().boot_point().await?;
+ Ok(Boot {
+ login,
+ resume_point: resume_point.to_string(),
+ })
}
#[derive(serde::Serialize)]
struct Boot {
login: Login,
+ resume_point: String,
}
impl IntoResponse for Boot {
@@ -50,7 +57,7 @@ async fn on_login(
Json(request): Json<LoginRequest>,
) -> Result<(IdentityToken, StatusCode), LoginError> {
let token = app
- .logins()
+ .tokens()
.login(&request.name, &request.password, &now)
.await
.map_err(LoginError)?;
@@ -66,6 +73,7 @@ impl IntoResponse for LoginError {
let Self(error) = self;
match error {
app::LoginError::Rejected => {
+ // not error::Unauthorized due to differing messaging
(StatusCode::UNAUTHORIZED, "invalid name or password").into_response()
}
other => Internal::from(other).into_response(),
@@ -85,8 +93,8 @@ async fn on_logout(
Json(LogoutRequest {}): Json<LogoutRequest>,
) -> Result<(IdentityToken, StatusCode), LogoutError> {
if let Some(secret) = identity.secret() {
- let (token, _) = app.logins().validate(&secret, &now).await?;
- app.logins().logout(&token).await?;
+ let (token, _) = app.tokens().validate(&secret, &now).await?;
+ app.tokens().logout(&token).await?;
}
let identity = identity.clear();
@@ -103,9 +111,7 @@ enum LogoutError {
impl IntoResponse for LogoutError {
fn into_response(self) -> Response {
match self {
- error @ Self::ValidateError(app::ValidateError::InvalidToken) => {
- (StatusCode::UNAUTHORIZED, error.to_string()).into_response()
- }
+ Self::ValidateError(app::ValidateError::InvalidToken) => Unauthorized.into_response(),
other => Internal::from(other).into_response(),
}
}
diff --git a/src/login/routes/test/boot.rs b/src/login/routes/test/boot.rs
index dee554f..9655354 100644
--- a/src/login/routes/test/boot.rs
+++ b/src/login/routes/test/boot.rs
@@ -1,9 +1,14 @@
+use axum::extract::State;
+
use crate::{login::routes, test::fixtures};
#[tokio::test]
async fn returns_identity() {
+ let app = fixtures::scratch_app().await;
let login = fixtures::login::fictitious();
- let response = routes::boot(login.clone()).await;
+ let response = routes::boot(State(app), login.clone())
+ .await
+ .expect("boot always succeeds");
assert_eq!(login, response.login);
}
diff --git a/src/login/routes/test/login.rs b/src/login/routes/test/login.rs
index 81653ff..3c82738 100644
--- a/src/login/routes/test/login.rs
+++ b/src/login/routes/test/login.rs
@@ -3,10 +3,7 @@ use axum::{
http::StatusCode,
};
-use crate::{
- login::{app, routes},
- test::fixtures,
-};
+use crate::{login::routes, test::fixtures, token::app};
#[tokio::test]
async fn new_identity() {
@@ -37,7 +34,7 @@ async fn new_identity() {
let validated_at = fixtures::now();
let (_, validated) = app
- .logins()
+ .tokens()
.validate(&secret, &validated_at)
.await
.expect("identity secret is valid");
@@ -74,7 +71,7 @@ async fn existing_identity() {
let validated_at = fixtures::now();
let (_, validated_login) = app
- .logins()
+ .tokens()
.validate(&secret, &validated_at)
.await
.expect("identity secret is valid");
@@ -127,14 +124,14 @@ async fn token_expires() {
// Verify the semantics
let expired_at = fixtures::now();
- app.logins()
+ app.tokens()
.expire(&expired_at)
.await
.expect("expiring tokens never fails");
let verified_at = fixtures::now();
let error = app
- .logins()
+ .tokens()
.validate(&secret, &verified_at)
.await
.expect_err("validating an expired token");
diff --git a/src/login/routes/test/logout.rs b/src/login/routes/test/logout.rs
index 20b0d55..42b2534 100644
--- a/src/login/routes/test/logout.rs
+++ b/src/login/routes/test/logout.rs
@@ -3,10 +3,7 @@ use axum::{
http::StatusCode,
};
-use crate::{
- login::{app, routes},
- test::fixtures,
-};
+use crate::{login::routes, test::fixtures, token::app};
#[tokio::test]
async fn successful() {
@@ -37,7 +34,7 @@ async fn successful() {
// Verify the semantics
let error = app
- .logins()
+ .tokens()
.validate(&secret, &now)
.await
.expect_err("secret is invalid");
diff --git a/src/login/types.rs b/src/login/types.rs
deleted file mode 100644
index 7c7cbf9..0000000
--- a/src/login/types.rs
+++ /dev/null
@@ -1,12 +0,0 @@
-use crate::repo::token;
-
-#[derive(Clone, Debug)]
-pub struct TokenRevoked {
- pub token: token::Id,
-}
-
-impl From<token::Id> for TokenRevoked {
- fn from(token: token::Id) -> Self {
- Self { token }
- }
-}