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;
fn auth_spec(state: &AppState) -> AuthSpec {
state
.app
.resources
.get("user")
.and_then(|r| r.auth.clone())
.unwrap_or_default()
}
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 user_r = match state.app.resources.get("user") {
Some(r) => r,
None => return error(500, "no user resource"),
};
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 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 resp,
}
}
Ok(None) => {}
Err(resp) => return 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 resp,
}
}
Ok(None) => {}
Err(resp) => return resp,
}
let created = match state.db.create(user_r, &data).await {
Ok(row) => row,
Err(e) => return 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 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 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 resp,
};
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 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 sql = format!(
"SELECT u.id::text AS id, u.{pw}::text AS password_hash \
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 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) {
match row
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
{
Some(id) => Some(id),
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(), rows.as_array().is_some_and(|a| a.is_empty())) {
(true, _) => Value::Null,
(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 => 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)
}
fn table(state: &AppState, name: &str) -> Option<String> {
state
.app
.resources
.get(name)
.map(|r| format!("\"{}\"", r.table_name()))
}