use apiplant_core::schema::AuthSpec;
use apiplant_core::{AuthEvent, HookEvent};
use ntex::web::types::{Json, State};
use ntex::web::{HttpRequest, HttpResponse};
use serde_json::{json, Value};
use uuid::Uuid;
use crate::crud::parse_query;
use crate::hooks::{self, HookRequest};
use crate::response::{db_error, error};
use crate::state::AppState;
pub(crate) const VERIFIED_AT_FIELD: &str = "email_verified_at";
pub(crate) fn auth_spec(state: &AppState) -> AuthSpec {
state
.app
.resources
.get("user")
.and_then(|r| r.auth.clone())
.unwrap_or_default()
}
pub(crate) fn quote(ident: &str) -> String {
if ident.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !ident.is_empty() {
format!("\"{ident}\"")
} else {
"\"__invalid__\"".to_string()
}
}
pub async fn register(
req: HttpRequest,
state: State<AppState>,
body: Json<serde_json::Map<String, Value>>,
) -> HttpResponse {
if !state.app.config.auth.allow_registration {
return error(403, "registration is disabled");
}
let spec = auth_spec(&state);
let mut data = body.into_inner();
let password = match data
.remove("password")
.and_then(|v| v.as_str().map(String::from))
{
Some(p) => p,
None => return error(400, "`password` is required"),
};
let hash = match state.auth.hash_password(&password) {
Ok(h) => h,
Err(_) => return error(500, "failed to hash password"),
};
data.insert(spec.password_field.clone(), Value::String(hash));
let (user_id, user) = match create_account(&state, &req, data).await {
Ok(created) => created,
Err(resp) => return resp,
};
if state.requires_email_verification() {
let address = user
.get(&spec.identity_field)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
if let Err(resp) = crate::email_auth::send_verification(&state, user_id, &address).await {
return resp;
}
return HttpResponse::Created().json(&json!({
"user": user,
"verification_required": true,
"message": "Check your email to confirm your address, then sign in.",
}));
}
match state.auth.issue_token(user_id) {
Ok(token) => HttpResponse::Created().json(&json!({ "token": token, "user": user })),
Err(_) => error(500, "failed to issue token"),
}
}
pub(crate) async fn create_account(
state: &AppState,
req: &HttpRequest,
mut data: serde_json::Map<String, Value>,
) -> Result<(Uuid, Value), HttpResponse> {
let Some(user_r) = state.app.resources.get("user") else {
return Err(error(500, "no user resource"));
};
let hook_req = HookRequest::new(req, &parse_query(req.query_string()), None, None);
match hooks::run_auth(
state,
user_r,
AuthEvent::BeforeRegister,
&hook_req,
Value::Object(data.clone()),
)
.await
{
Ok(Some(replacement)) => {
let hook = user_r
.auth_hook(AuthEvent::BeforeRegister)
.unwrap_or_default();
match hooks::replacement_object(replacement, hook) {
Ok(map) => data = map,
Err(resp) => return Err(resp),
}
}
Ok(None) => {}
Err(resp) => return Err(resp),
}
match hooks::run(
state,
user_r,
HookEvent::BeforeCreate,
&hook_req,
Value::Object(data.clone()),
)
.await
{
Ok(Some(replacement)) => {
let hook = user_r.hook(HookEvent::BeforeCreate).unwrap_or_default();
match hooks::replacement_object(replacement, hook) {
Ok(map) => data = map,
Err(resp) => return Err(resp),
}
}
Ok(None) => {}
Err(resp) => return Err(resp),
}
let created = match state.db.create(user_r, &data).await {
Ok(row) => row,
Err(e) => return Err(db_error(e)),
};
let user_id = created
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok());
let Some(user_id) = user_id else {
return Err(error(500, "created user missing id"));
};
let hook_req = hook_req.with_record(user_id);
let user = match hooks::run(
state,
user_r,
HookEvent::AfterCreate,
&hook_req,
created.clone(),
)
.await
{
Ok(Some(replacement)) => replacement,
Ok(None) => created,
Err(resp) => return Err(resp),
};
let user = match hooks::run_auth(
state,
user_r,
AuthEvent::AfterRegister,
&hook_req,
user.clone(),
)
.await
{
Ok(Some(replacement)) => replacement,
Ok(None) => user,
Err(resp) => return Err(resp),
};
create_personal_organization(state, user_id, &user).await;
Ok((user_id, user))
}
pub(crate) async fn create_personal_organization(state: &AppState, user_id: Uuid, user: &Value) {
let (Some(org_r), Some(membership_r)) = (
state.app.resources.get("organization"),
state.app.resources.get("membership"),
) else {
return;
};
let name = personal_org_name(state, user);
let mut org = serde_json::Map::new();
org.insert("name".into(), Value::String(name.clone()));
if org_r.fields.contains_key("slug") {
org.insert(
"slug".into(),
Value::String(personal_org_slug(&name, user_id)),
);
}
let created = match state.db.create(org_r, &org).await {
Ok(row) => row,
Err(e) => {
tracing::error!(error = %e, "failed to create the personal organization");
return;
}
};
let Some(org_id) = created.get("id").and_then(|v| v.as_str()) else {
tracing::error!("created personal organization is missing an id");
return;
};
let mut m = serde_json::Map::new();
m.insert("user_id".into(), Value::String(user_id.to_string()));
m.insert("organization_id".into(), Value::String(org_id.to_string()));
m.insert("role".into(), Value::String("admin".into()));
if let Err(e) = state.db.create(membership_r, &m).await {
tracing::error!(error = %e, "failed to create the personal membership");
}
}
fn personal_org_name(state: &AppState, user: &Value) -> String {
let spec = auth_spec(state);
for field in ["name", "full_name", "display_name"] {
if let Some(value) = user.get(field).and_then(|v| v.as_str()) {
let value = value.trim();
if !value.is_empty() {
return value.to_string();
}
}
}
let identity = user
.get(&spec.identity_field)
.and_then(|v| v.as_str())
.unwrap_or_default();
let local = identity.split('@').next().unwrap_or("").trim();
if local.is_empty() {
"Personal".to_string()
} else {
local.to_string()
}
}
fn personal_org_slug(name: &str, user_id: Uuid) -> String {
let base: String = name
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let base = base.trim_matches('-').replace("--", "-");
let base: String = base.chars().take(32).collect();
let base = base.trim_matches('-');
let short = &user_id.simple().to_string()[..8];
if base.is_empty() {
format!("personal-{short}")
} else {
format!("{base}-{short}")
}
}
pub async fn login(
req: HttpRequest,
state: State<AppState>,
body: Json<serde_json::Map<String, Value>>,
) -> HttpResponse {
let spec = auth_spec(&state);
let data = body.into_inner();
let mut identity = match data.get(&spec.identity_field).and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => return error(400, format!("`{}` is required", spec.identity_field)),
};
let password = match data.get("password").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => return error(400, "`password` is required"),
};
let Some(user_r) = state.app.resources.get("user") else {
return error(500, "missing user resource");
};
let hook_req = HookRequest::new(&req, &parse_query(req.query_string()), None, None);
match hooks::run_auth(
&state,
user_r,
AuthEvent::BeforeLogin,
&hook_req,
json!({ spec.identity_field.clone(): identity }),
)
.await
{
Ok(Some(replacement)) => {
match replacement
.get(&spec.identity_field)
.and_then(|v| v.as_str())
{
Some(rewritten) => identity = rewritten.to_string(),
None => {
return error(
500,
format!(
"`before_login` replaced the credentials without `{}`",
spec.identity_field
),
)
}
}
}
Ok(None) => {}
Err(resp) => return resp,
}
let Some(user_tbl) = table(&state, "user") else {
return error(500, "missing user resource");
};
let verification_required =
state.requires_email_verification() && user_r.fields.contains_key(VERIFIED_AT_FIELD);
let verified_column = if verification_required {
format!(", u.{VERIFIED_AT_FIELD} IS NOT NULL AS verified")
} else {
", true AS verified".to_string()
};
let sql = format!(
"SELECT u.id::text AS id, u.{pw}::text AS password_hash{verified_column} \
FROM {user_tbl} u WHERE u.{ident} = $1 LIMIT 1",
pw = quote(&spec.password_field),
ident = quote(&spec.identity_field),
);
let rows = match state
.db
.raw_json(&sql, &[Value::String(identity.clone())])
.await
{
Ok(v) => v,
Err(e) => return db_error(e),
};
let mut unconfirmed = false;
let verified = match rows.as_array().and_then(|a| a.first()) {
None => {
let _ = state.auth.hash_password(&password);
None
}
Some(row) => {
let stored = row
.get("password_hash")
.and_then(|v| v.as_str())
.unwrap_or("");
if state.auth.verify_password(&password, stored) {
unconfirmed = row.get("verified").and_then(|v| v.as_bool()) == Some(false);
match row
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
{
Some(id) if !unconfirmed => Some(id),
Some(_) => None,
None => return error(500, "user missing id"),
}
} else {
None
}
}
};
let token = match verified {
Some(user_id) => match state.auth.issue_token(user_id) {
Ok(token) => Some(token),
Err(_) => return error(500, "failed to issue token"),
},
None => None,
};
let outcome = json!({
"success": verified.is_some(),
"user_id": verified.map(|id| id.to_string()),
"identity": identity,
"reason": match (verified.is_some(), unconfirmed, rows.as_array().is_some_and(|a| a.is_empty())) {
(true, _, _) => Value::Null,
(false, true, _) => json!("email_unverified"),
(false, _, true) => json!("unknown_identity"),
(false, _, false) => json!("bad_password"),
},
});
let hook_req = match verified {
Some(user_id) => hook_req.with_record(user_id),
None => hook_req,
};
let mut response = match &token {
Some(token) => json!({ "token": token }),
None => Value::Null,
};
match hooks::run_auth(&state, user_r, AuthEvent::AfterLogin, &hook_req, outcome).await {
Ok(Some(replacement)) if token.is_some() => {
merge_beside(&mut response, replacement, "token")
}
Ok(_) => {}
Err(resp) => return resp,
}
match token {
Some(_) => HttpResponse::Ok().json(&response),
None if unconfirmed => HttpResponse::Forbidden().json(&json!({
"error": "confirm your email address before signing in",
"reason": "email_unverified",
})),
None => error(401, "invalid credentials"),
}
}
fn merge_beside(response: &mut Value, replacement: Value, reserved: &str) {
let (Some(target), Value::Object(fields)) = (response.as_object_mut(), replacement) else {
return;
};
for (key, value) in fields {
if key != reserved {
target.insert(key, value);
}
}
}
pub async fn me(req: HttpRequest, state: State<AppState>) -> HttpResponse {
let Some(principal) = state.resolve_principal(&req).await else {
return error(401, "authentication required");
};
let Some(user_tbl) = table(&state, "user") else {
return error(500, "missing user resource");
};
let sql = format!("SELECT id::text AS id FROM {user_tbl} WHERE id = $1::uuid LIMIT 1");
let rows = match state
.db
.raw_json(&sql, &[Value::String(principal.user_id.to_string())])
.await
{
Ok(v) => v,
Err(e) => return db_error(e),
};
if rows.as_array().is_none_or(|a| a.is_empty()) {
return error(401, "user no longer exists");
}
HttpResponse::Ok().json(&json!({ "user_id": principal.user_id.to_string() }))
}
pub async fn create_api_key(
req: HttpRequest,
state: State<AppState>,
body: Json<Value>,
) -> HttpResponse {
let principal = match state.resolve_principal(&req).await {
Some(p) => p,
None => return error(401, "authentication required"),
};
let api_key_r = match state.app.resources.get("api_key") {
Some(r) => r,
None => return error(500, "no api_key resource"),
};
let (plaintext, hash) = state.auth.generate_api_key();
let mut data = serde_json::Map::new();
if let Some(name) = body.get("name").and_then(|v| v.as_str()) {
data.insert("name".into(), Value::String(name.to_string()));
}
data.insert("token_hash".into(), Value::String(hash));
data.insert(
"owner_id".into(),
Value::String(principal.user_id.to_string()),
);
let active_org = state.active_org(&req, &Some(principal.clone()));
let hook_req = HookRequest::new(
&req,
&parse_query(req.query_string()),
Some(&principal),
active_org,
);
match hooks::run_auth(
&state,
api_key_r,
AuthEvent::BeforeApiKey,
&hook_req,
Value::Object(data.clone()),
)
.await
{
Ok(Some(replacement)) => {
let hook = state
.app
.resources
.get("user")
.and_then(|u| u.auth_hook(AuthEvent::BeforeApiKey))
.unwrap_or_default();
match hooks::replacement_object(replacement, hook) {
Ok(map) => data = map,
Err(resp) => return resp,
}
}
Ok(None) => {}
Err(resp) => return resp,
}
let row = match state.db.create(api_key_r, &data).await {
Ok(row) => row,
Err(e) => return db_error(e),
};
let mut response = json!({
"api_key": plaintext,
"id": row.get("id").cloned().unwrap_or(Value::Null),
"note": "store this key now; it will not be shown again",
});
let hook_req = match row.get("id").and_then(|v| v.as_str()) {
Some(id) => match Uuid::parse_str(id) {
Ok(id) => hook_req.with_record(id),
Err(_) => hook_req,
},
None => hook_req,
};
match hooks::run_auth(
&state,
api_key_r,
AuthEvent::AfterApiKey,
&hook_req,
row.clone(),
)
.await
{
Ok(Some(replacement)) => merge_beside(&mut response, replacement, "api_key"),
Ok(None) => {}
Err(resp) => return resp,
}
HttpResponse::Created().json(&response)
}
pub(crate) fn table(state: &AppState, name: &str) -> Option<String> {
state
.app
.resources
.get(name)
.map(|r| format!("\"{}\"", r.table_name()))
}