use apiplant_auth::Authenticator;
use chrono::{DateTime, Duration, Utc};
use ntex::web::types::{Json, Path, State};
use ntex::web::{HttpRequest, HttpResponse};
use serde_json::{json, Map, Value};
use uuid::Uuid;
use crate::auth_routes::{auth_spec, quote, table, VERIFIED_AT_FIELD};
use crate::emails::{self, Links};
use crate::response::{db_error, error};
use crate::state::AppState;
const KIND_VERIFICATION: &str = "email_verification";
const KIND_RESET: &str = "password_reset";
pub async fn create_invitation(
req: HttpRequest,
state: State<AppState>,
body: Json<Value>,
) -> HttpResponse {
let Some(principal) = state.resolve_principal(&req).await else {
return error(401, "authentication required");
};
let Some(org) = state.active_org(&req, &Some(principal.clone())) else {
return error(400, "no active organization — pick one with X-Organization");
};
if !may_invite(&state, &principal, org) {
return error(403, "you may not add people to this organization");
}
let Some(invitation_r) = state.app.resources.get("invitation") else {
return error(500, "no invitation resource");
};
let spec = auth_spec(&state);
let address = body
.get("email")
.or_else(|| body.get(&spec.identity_field))
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or_default()
.to_string();
if address.is_empty() {
return error(400, "`email` is required");
}
let role = body
.get("role")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|role| !role.is_empty())
.unwrap_or("member")
.to_string();
match already_a_member(&state, &address, org).await {
Ok(true) => return error(409, "they are already in this organization"),
Ok(false) => {}
Err(resp) => return resp,
}
if let Some(invitation_tbl) = table(&state, "invitation") {
let sql = format!(
"DELETE FROM {invitation_tbl} \
WHERE organization_id = $1::uuid AND lower(email) = lower($2) \
AND accepted_at IS NULL"
);
if let Err(e) = state
.db
.raw_json(
&sql,
&[
Value::String(org.to_string()),
Value::String(address.clone()),
],
)
.await
{
return db_error(e);
}
}
let ttl = state.app.config.auth.invite_ttl_secs;
let (plaintext, hash) = Authenticator::generate_link_token("inv");
let mut data = Map::new();
data.insert("email".into(), Value::String(address.clone()));
data.insert("role".into(), Value::String(role.clone()));
data.insert("token_hash".into(), Value::String(hash));
data.insert("organization_id".into(), Value::String(org.to_string()));
data.insert(
"invited_by".into(),
Value::String(principal.user_id.to_string()),
);
data.insert("expires_at".into(), Value::String(rfc3339_in(ttl as i64)));
let row = match state.db.create(invitation_r, &data).await {
Ok(row) => row,
Err(e) => return db_error(e),
};
let organization = organization_name(&state, org).await;
let inviter = inviter_name(&state, principal.user_id).await;
let message = emails::invitation(
&Links::from_app(&state.app),
&organization,
inviter.as_deref(),
&plaintext,
&emails::humanise(ttl),
);
if let Err(resp) = send(&state, message.to(&address)).await {
if let (Some(invitation_tbl), Some(id)) = (
table(&state, "invitation"),
row.get("id").and_then(|v| v.as_str()),
) {
let sql = format!("DELETE FROM {invitation_tbl} WHERE id = $1::uuid");
let _ = state
.db
.raw_json(&sql, &[Value::String(id.to_string())])
.await;
}
return resp;
}
HttpResponse::Created().json(&json!({ "invitation": row }))
}
pub async fn preview_invitation(state: State<AppState>, token: Path<String>) -> HttpResponse {
let invitation = match live_invitation(&state, &token).await {
Ok(row) => row,
Err(resp) => return resp,
};
let org = invitation
.get("organization_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok());
let organization = match org {
Some(org) => organization_name(&state, org).await,
None => state.app.display_name(),
};
let address = invitation
.get("email")
.and_then(|v| v.as_str())
.unwrap_or_default();
let has_account = match find_user_by_identity(&state, address).await {
Ok(found) => found.is_some(),
Err(resp) => return resp,
};
HttpResponse::Ok().json(&json!({
"email": address,
"organization": organization,
"role": invitation.get("role").cloned().unwrap_or(Value::Null),
"expires_at": invitation.get("expires_at").cloned().unwrap_or(Value::Null),
"has_account": has_account,
"identity_field": auth_spec(&state).identity_field,
}))
}
pub async fn accept_invitation(
req: HttpRequest,
state: State<AppState>,
token: Path<String>,
body: Json<Map<String, Value>>,
) -> HttpResponse {
let invitation = match live_invitation(&state, &token).await {
Ok(row) => row,
Err(resp) => return resp,
};
let (Some(invitation_id), Some(org), address) = (
invitation
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok()),
invitation
.get("organization_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok()),
invitation
.get("email")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
) else {
return error(500, "invitation is missing its organization");
};
let spec = auth_spec(&state);
let user_id = match find_user_by_identity(&state, &address).await {
Ok(Some(id)) => id,
Err(resp) => return resp,
Ok(None) => {
let mut data = body.into_inner();
let password = match data
.remove("password")
.and_then(|v| v.as_str().map(String::from))
.filter(|p| !p.is_empty())
{
Some(password) => password,
None => return error(400, "`password` is required to create your account"),
};
let hash = match state.auth.hash_password(&password) {
Ok(hash) => hash,
Err(_) => return error(500, "failed to hash password"),
};
data.insert(spec.identity_field.clone(), Value::String(address.clone()));
data.insert(spec.password_field.clone(), Value::String(hash));
if state
.app
.resources
.get("user")
.is_some_and(|user| user.fields.contains_key(VERIFIED_AT_FIELD))
{
data.insert(VERIFIED_AT_FIELD.into(), Value::String(rfc3339_in(0)));
}
match crate::auth_routes::create_account(&state, &req, data).await {
Ok((id, _)) => id,
Err(resp) => return resp,
}
}
};
if let Err(resp) = ensure_membership(
&state,
user_id,
org,
invitation.get("role").and_then(|v| v.as_str()),
)
.await
{
return resp;
}
if let Some(invitation_tbl) = table(&state, "invitation") {
let sql = format!("UPDATE {invitation_tbl} SET accepted_at = now() WHERE id = $1::uuid");
if let Err(e) = state
.db
.raw_json(&sql, &[Value::String(invitation_id.to_string())])
.await
{
return db_error(e);
}
}
match state.auth.issue_token(user_id) {
Ok(session) => HttpResponse::Ok().json(&json!({
"token": session,
"organization_id": org.to_string(),
})),
Err(_) => error(500, "failed to issue token"),
}
}
pub async fn send_verification(
state: &AppState,
user_id: Uuid,
address: &str,
) -> Result<(), HttpResponse> {
if !deliverable(address) {
return Ok(());
}
let ttl = state.app.config.auth.verification_ttl_secs;
let plaintext = mint_token(state, user_id, KIND_VERIFICATION, ttl).await?;
let message = emails::verification(
&Links::from_app(&state.app),
&plaintext,
&emails::humanise(ttl),
);
send(state, message.to(address)).await
}
pub async fn verify_email(state: State<AppState>, body: Json<Value>) -> HttpResponse {
let Some(token) = body.get("token").and_then(|v| v.as_str()) else {
return error(400, "`token` is required");
};
let user_id = match spend_token(&state, token, KIND_VERIFICATION).await {
Ok(id) => id,
Err(resp) => return resp,
};
let Some(user_tbl) = table(&state, "user") else {
return error(500, "missing user resource");
};
let sql = format!(
"UPDATE {user_tbl} SET {VERIFIED_AT_FIELD} = coalesce({VERIFIED_AT_FIELD}, now()) \
WHERE id = $1::uuid"
);
if let Err(e) = state
.db
.raw_json(&sql, &[Value::String(user_id.to_string())])
.await
{
return db_error(e);
}
match state.auth.issue_token(user_id) {
Ok(session) => HttpResponse::Ok().json(&json!({ "token": session, "verified": true })),
Err(_) => error(500, "failed to issue token"),
}
}
pub async fn resend_verification(state: State<AppState>, body: Json<Value>) -> HttpResponse {
let spec = auth_spec(&state);
let address = body
.get("email")
.or_else(|| body.get(&spec.identity_field))
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or_default()
.to_string();
if !address.is_empty() {
if let Ok(Some(user_id)) = find_unverified_user(&state, &address).await {
if let Err(_resp) = send_verification(&state, user_id, &address).await {
tracing::warn!("could not send a verification email");
}
}
}
accepted("If that address needs confirming, a new link is on its way.")
}
pub async fn forgot_password(state: State<AppState>, body: Json<Value>) -> HttpResponse {
let spec = auth_spec(&state);
let address = body
.get("email")
.or_else(|| body.get(&spec.identity_field))
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or_default()
.to_string();
if !address.is_empty() && deliverable(&address) {
if let Ok(Some(user_id)) = find_user_by_identity(&state, &address).await {
let ttl = state.app.config.auth.password_reset_ttl_secs;
match mint_token(&state, user_id, KIND_RESET, ttl).await {
Ok(plaintext) => {
let message = emails::password_reset(
&Links::from_app(&state.app),
&plaintext,
&emails::humanise(ttl),
);
if send(&state, message.to(&address)).await.is_err() {
tracing::warn!("could not send a password reset email");
}
}
Err(_) => tracing::warn!("could not mint a password reset token"),
}
}
}
accepted("If that address has an account, a reset link is on its way.")
}
pub async fn reset_password(state: State<AppState>, body: Json<Value>) -> HttpResponse {
let Some(token) = body.get("token").and_then(|v| v.as_str()) else {
return error(400, "`token` is required");
};
let Some(password) = body
.get("password")
.and_then(|v| v.as_str())
.filter(|p| !p.is_empty())
else {
return error(400, "`password` is required");
};
let user_id = match spend_token(&state, token, KIND_RESET).await {
Ok(id) => id,
Err(resp) => return resp,
};
let hash = match state.auth.hash_password(password) {
Ok(hash) => hash,
Err(_) => return error(500, "failed to hash password"),
};
let spec = auth_spec(&state);
let Some(user_tbl) = table(&state, "user") else {
return error(500, "missing user resource");
};
let sql = format!(
"UPDATE {user_tbl} \
SET {pw} = $1, {VERIFIED_AT_FIELD} = coalesce({VERIFIED_AT_FIELD}, now()) \
WHERE id = $2::uuid",
pw = quote(&spec.password_field),
);
if let Err(e) = state
.db
.raw_json(
&sql,
&[Value::String(hash), Value::String(user_id.to_string())],
)
.await
{
return db_error(e);
}
if let Some(token_tbl) = table(&state, "auth_token") {
let sql = format!(
"UPDATE {token_tbl} SET used_at = now() \
WHERE user_id = $1::uuid AND kind = $2 AND used_at IS NULL"
);
let _ = state
.db
.raw_json(
&sql,
&[
Value::String(user_id.to_string()),
Value::String(KIND_RESET.into()),
],
)
.await;
}
match state.auth.issue_token(user_id) {
Ok(session) => HttpResponse::Ok().json(&json!({ "token": session })),
Err(_) => error(500, "failed to issue token"),
}
}
fn may_invite(state: &AppState, principal: &apiplant_auth::Principal, org: Uuid) -> bool {
invite_policy(
state
.app
.resources
.get("membership")
.map(|membership| &membership.permissions.create),
principal,
org,
)
}
fn invite_policy(
create: Option<&apiplant_core::Access>,
principal: &apiplant_auth::Principal,
org: Uuid,
) -> bool {
use apiplant_core::Access;
match create {
Some(Access::Role(role)) => principal.has_role_in(org, role),
Some(Access::Member | Access::Owner | Access::Authenticated) => principal.is_member(org),
_ => principal.is_admin_of(org),
}
}
async fn live_invitation(state: &AppState, token: &str) -> Result<Value, HttpResponse> {
let Some(invitation_tbl) = table(state, "invitation") else {
return Err(error(500, "no invitation resource"));
};
let hash = Authenticator::hash_link_token(token.trim());
let sql = format!(
"SELECT id::text AS id, email, role, organization_id::text AS organization_id, \
expires_at::text AS expires_at \
FROM {invitation_tbl} \
WHERE token_hash = $1 AND accepted_at IS NULL AND expires_at > now() \
LIMIT 1"
);
let rows = state
.db
.raw_json(&sql, &[Value::String(hash)])
.await
.map_err(db_error)?;
match rows.as_array().and_then(|rows| rows.first()) {
Some(row) => Ok(row.clone()),
None => Err(error(404, "this invitation is no longer valid")),
}
}
async fn mint_token(
state: &AppState,
user_id: Uuid,
kind: &str,
ttl_secs: u64,
) -> Result<String, HttpResponse> {
let Some(token_r) = state.app.resources.get("auth_token") else {
return Err(error(500, "no auth_token resource"));
};
let prefix = if kind == KIND_RESET {
"reset"
} else {
"verify"
};
let (plaintext, hash) = Authenticator::generate_link_token(prefix);
let mut data = Map::new();
data.insert("user_id".into(), Value::String(user_id.to_string()));
data.insert("kind".into(), Value::String(kind.to_string()));
data.insert("token_hash".into(), Value::String(hash));
data.insert(
"expires_at".into(),
Value::String(rfc3339_in(ttl_secs as i64)),
);
state
.db
.create(token_r, &data)
.await
.map_err(db_error)
.map(|_| plaintext)
}
async fn spend_token(state: &AppState, token: &str, kind: &str) -> Result<Uuid, HttpResponse> {
let Some(token_tbl) = table(state, "auth_token") else {
return Err(error(500, "no auth_token resource"));
};
let hash = Authenticator::hash_link_token(token.trim());
let params = [Value::String(hash), Value::String(kind.to_string())];
let claim = format!(
"UPDATE {token_tbl} SET used_at = now() \
WHERE token_hash = $1 AND kind = $2 AND used_at IS NULL AND expires_at > now()"
);
let claimed = state
.db
.raw_json(&claim, ¶ms)
.await
.map_err(db_error)?
.get("rows_affected")
.and_then(|v| v.as_u64())
.unwrap_or(0);
if claimed == 0 {
return Err(error(404, "this link is no longer valid"));
}
let lookup = format!(
"SELECT user_id::text AS user_id FROM {token_tbl} \
WHERE token_hash = $1 AND kind = $2 LIMIT 1"
);
let rows = state
.db
.raw_json(&lookup, ¶ms)
.await
.map_err(db_error)?;
rows.as_array()
.and_then(|rows| rows.first())
.and_then(|row| row.get("user_id"))
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
.ok_or_else(|| error(404, "this link is no longer valid"))
}
async fn find_user_by_identity(
state: &AppState,
address: &str,
) -> Result<Option<Uuid>, HttpResponse> {
let spec = auth_spec(state);
let Some(user_tbl) = table(state, "user") else {
return Err(error(500, "missing user resource"));
};
let sql = format!(
"SELECT id::text AS id FROM {user_tbl} WHERE lower({ident}) = lower($1) LIMIT 1",
ident = quote(&spec.identity_field),
);
let rows = state
.db
.raw_json(&sql, &[Value::String(address.to_string())])
.await
.map_err(db_error)?;
Ok(rows
.as_array()
.and_then(|rows| rows.first())
.and_then(|row| row.get("id"))
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok()))
}
async fn find_unverified_user(
state: &AppState,
address: &str,
) -> Result<Option<Uuid>, HttpResponse> {
let spec = auth_spec(state);
let Some(user_tbl) = table(state, "user") else {
return Err(error(500, "missing user resource"));
};
let sql = format!(
"SELECT id::text AS id FROM {user_tbl} \
WHERE lower({ident}) = lower($1) AND {VERIFIED_AT_FIELD} IS NULL LIMIT 1",
ident = quote(&spec.identity_field),
);
let rows = state
.db
.raw_json(&sql, &[Value::String(address.to_string())])
.await
.map_err(db_error)?;
Ok(rows
.as_array()
.and_then(|rows| rows.first())
.and_then(|row| row.get("id"))
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok()))
}
async fn already_a_member(
state: &AppState,
address: &str,
org: Uuid,
) -> Result<bool, HttpResponse> {
let Some(user_id) = find_user_by_identity(state, address).await? else {
return Ok(false);
};
let Some(membership_tbl) = table(state, "membership") else {
return Ok(false);
};
let sql = format!(
"SELECT 1 AS hit FROM {membership_tbl} \
WHERE user_id = $1::uuid AND organization_id = $2::uuid LIMIT 1"
);
let rows = state
.db
.raw_json(
&sql,
&[
Value::String(user_id.to_string()),
Value::String(org.to_string()),
],
)
.await
.map_err(db_error)?;
Ok(rows.as_array().is_some_and(|rows| !rows.is_empty()))
}
async fn ensure_membership(
state: &AppState,
user_id: Uuid,
org: Uuid,
role: Option<&str>,
) -> Result<(), HttpResponse> {
let Some(membership_r) = state.app.resources.get("membership") else {
return Err(error(500, "no membership resource"));
};
let Some(membership_tbl) = table(state, "membership") else {
return Err(error(500, "no membership resource"));
};
let sql = format!(
"SELECT 1 AS hit FROM {membership_tbl} \
WHERE user_id = $1::uuid AND organization_id = $2::uuid LIMIT 1"
);
let rows = state
.db
.raw_json(
&sql,
&[
Value::String(user_id.to_string()),
Value::String(org.to_string()),
],
)
.await
.map_err(db_error)?;
if rows.as_array().is_some_and(|rows| !rows.is_empty()) {
return Ok(());
}
let mut data = Map::new();
data.insert("user_id".into(), Value::String(user_id.to_string()));
data.insert("organization_id".into(), Value::String(org.to_string()));
if let Some(role) = role.filter(|role| !role.is_empty()) {
data.insert("role".into(), Value::String(role.to_string()));
}
state
.db
.create(membership_r, &data)
.await
.map_err(db_error)
.map(|_| ())
}
async fn organization_name(state: &AppState, org: Uuid) -> String {
let fallback = || state.app.display_name();
let Some(org_tbl) = table(state, "organization") else {
return fallback();
};
let sql = format!("SELECT name FROM {org_tbl} WHERE id = $1::uuid LIMIT 1");
let rows = match state
.db
.raw_json(&sql, &[Value::String(org.to_string())])
.await
{
Ok(rows) => rows,
Err(_) => return fallback(),
};
rows.as_array()
.and_then(|rows| rows.first())
.and_then(|row| row.get("name"))
.and_then(|v| v.as_str())
.filter(|name| !name.is_empty())
.map(str::to_owned)
.unwrap_or_else(fallback)
}
async fn inviter_name(state: &AppState, user_id: Uuid) -> Option<String> {
let spec = auth_spec(state);
let user_r = state.app.resources.get("user")?;
let user_tbl = table(state, "user")?;
let mut candidates: Vec<String> = Vec::new();
for name in ["display_name", "name"] {
if user_r.fields.contains_key(name) {
candidates.push(quote(name));
}
}
candidates.push(quote(&spec.identity_field));
let sql = format!(
"SELECT coalesce({}) AS who FROM {user_tbl} WHERE id = $1::uuid LIMIT 1",
candidates.join(", "),
);
let rows = state
.db
.raw_json(&sql, &[Value::String(user_id.to_string())])
.await
.ok()?;
rows.as_array()?
.first()?
.get("who")?
.as_str()
.filter(|who| !who.is_empty())
.map(str::to_owned)
}
async fn send(state: &AppState, message: apiplant_email::Message) -> Result<(), HttpResponse> {
let Some(mailer) = &state.mailer else {
return Err(error(502, "this server cannot send email"));
};
match mailer.send(&message).await {
Ok(_) => Ok(()),
Err(e) => {
tracing::error!(error = %e, "could not send an email");
Err(error(502, "could not send the email — try again shortly"))
}
}
}
fn deliverable(address: &str) -> bool {
let deliverable = !address
.rsplit_once('@')
.map(|(_, domain)| {
let domain = domain.trim().trim_end_matches('.').to_lowercase();
domain == "invalid" || domain.ends_with(".invalid")
})
.unwrap_or(false);
if !deliverable {
tracing::debug!("not mailing an address at a reserved `.invalid` domain");
}
deliverable
}
fn accepted(message: &str) -> HttpResponse {
HttpResponse::Accepted().json(&json!({ "message": message }))
}
fn rfc3339_in(secs: i64) -> String {
let at: DateTime<Utc> = Utc::now() + Duration::seconds(secs);
at.to_rfc3339()
}
#[cfg(test)]
mod tests {
use super::*;
use apiplant_core::Access;
fn principal(org: Uuid, role: &str) -> apiplant_auth::Principal {
apiplant_auth::Principal {
user_id: Uuid::new_v4(),
organizations: vec![apiplant_auth::OrgMembership::new(
org,
Some(role.to_string()),
[],
)],
}
}
#[test]
fn who_may_invite_follows_who_may_add_a_member() {
let org = Uuid::new_v4();
let admins = Access::Role("admin".into());
assert!(invite_policy(Some(&admins), &principal(org, "admin"), org));
assert!(!invite_policy(
Some(&admins),
&principal(org, "member"),
org
));
let members = Access::Member;
assert!(invite_policy(
Some(&members),
&principal(org, "member"),
org
));
assert!(!invite_policy(
Some(&members),
&principal(org, "member"),
Uuid::new_v4()
));
}
#[test]
fn a_policy_that_is_not_a_role_check_falls_back_to_admin() {
let org = Uuid::new_v4();
for policy in [Some(&Access::Public), None] {
assert!(!invite_policy(policy, &principal(org, "member"), org));
assert!(invite_policy(policy, &principal(org, "admin"), org));
}
}
}