685 lines
22 KiB
Rust
685 lines
22 KiB
Rust
//! Authentication routes
|
|
|
|
use axum::{
|
|
extract::{Query, State},
|
|
http::HeaderMap,
|
|
response::{IntoResponse, Redirect, Response},
|
|
routing::{get, post},
|
|
Json, Router,
|
|
};
|
|
|
|
use validator::Validate;
|
|
|
|
use attune_common::repositories::{
|
|
identity::{CreateIdentityInput, IdentityRepository},
|
|
Create, FindById,
|
|
};
|
|
|
|
use crate::{
|
|
auth::{
|
|
hash_password,
|
|
jwt::{
|
|
generate_access_token, generate_refresh_token, generate_sensor_token, validate_token,
|
|
TokenType,
|
|
},
|
|
middleware::RequireAuth,
|
|
oidc::{
|
|
apply_cookies_to_headers, build_login_redirect, build_logout_redirect,
|
|
cookie_authenticated_user, get_cookie_value, oidc_callback_redirect_response,
|
|
OidcCallbackQuery, REFRESH_COOKIE_NAME,
|
|
},
|
|
verify_password,
|
|
},
|
|
dto::{
|
|
ApiResponse, AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse,
|
|
LoginRequest, RefreshTokenRequest, RegisterRequest, SuccessResponse, TokenResponse,
|
|
},
|
|
middleware::error::ApiError,
|
|
state::SharedState,
|
|
};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use utoipa::ToSchema;
|
|
|
|
/// Request body for creating sensor tokens
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)]
|
|
pub struct CreateSensorTokenRequest {
|
|
/// Sensor reference (e.g., "core.timer")
|
|
#[validate(length(min = 1, max = 255))]
|
|
pub sensor_ref: String,
|
|
|
|
/// List of trigger types this sensor can create events for
|
|
#[validate(length(min = 1))]
|
|
pub trigger_types: Vec<String>,
|
|
|
|
/// Optional TTL in seconds (default: 86400 = 24 hours, max: 259200 = 72 hours)
|
|
#[validate(range(min = 3600, max = 259200))]
|
|
pub ttl_seconds: Option<i64>,
|
|
}
|
|
|
|
/// Response for sensor token creation
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
pub struct SensorTokenResponse {
|
|
pub identity_id: i64,
|
|
pub sensor_ref: String,
|
|
pub token: String,
|
|
pub expires_at: String,
|
|
pub trigger_types: Vec<String>,
|
|
}
|
|
|
|
/// Create authentication routes
|
|
pub fn routes() -> Router<SharedState> {
|
|
Router::new()
|
|
.route("/settings", get(auth_settings))
|
|
.route("/login", post(login))
|
|
.route("/oidc/login", get(oidc_login))
|
|
.route("/callback", get(oidc_callback))
|
|
.route("/ldap/login", post(ldap_login))
|
|
.route("/logout", get(logout))
|
|
.route("/register", post(register))
|
|
.route("/refresh", post(refresh_token))
|
|
.route("/me", get(get_current_user))
|
|
.route("/change-password", post(change_password))
|
|
.route("/sensor-token", post(create_sensor_token))
|
|
.route("/internal/sensor-token", post(create_sensor_token_internal))
|
|
}
|
|
|
|
/// Authentication settings endpoint
|
|
///
|
|
/// GET /auth/settings
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/auth/settings",
|
|
tag = "auth",
|
|
responses(
|
|
(status = 200, description = "Authentication settings", body = inline(ApiResponse<AuthSettingsResponse>))
|
|
)
|
|
)]
|
|
pub async fn auth_settings(
|
|
State(state): State<SharedState>,
|
|
) -> Result<Json<ApiResponse<AuthSettingsResponse>>, ApiError> {
|
|
let oidc = state
|
|
.config
|
|
.security
|
|
.oidc
|
|
.as_ref()
|
|
.filter(|oidc| oidc.enabled);
|
|
|
|
let ldap = state
|
|
.config
|
|
.security
|
|
.ldap
|
|
.as_ref()
|
|
.filter(|ldap| ldap.enabled);
|
|
|
|
let response = AuthSettingsResponse {
|
|
authentication_enabled: state.config.security.enable_auth,
|
|
local_password_enabled: state.config.security.enable_auth,
|
|
local_password_visible_by_default: state.config.security.enable_auth
|
|
&& state.config.security.login_page.show_local_login,
|
|
oidc_enabled: oidc.is_some(),
|
|
oidc_visible_by_default: oidc.is_some() && state.config.security.login_page.show_oidc_login,
|
|
oidc_provider_name: oidc.map(|oidc| oidc.provider_name.clone()),
|
|
oidc_provider_label: oidc.map(|oidc| {
|
|
oidc.provider_label
|
|
.clone()
|
|
.unwrap_or_else(|| oidc.provider_name.clone())
|
|
}),
|
|
oidc_provider_icon_url: oidc.and_then(|oidc| oidc.provider_icon_url.clone()),
|
|
ldap_enabled: ldap.is_some(),
|
|
ldap_visible_by_default: ldap.is_some() && state.config.security.login_page.show_ldap_login,
|
|
ldap_provider_name: ldap.map(|ldap| ldap.provider_name.clone()),
|
|
ldap_provider_label: ldap.map(|ldap| {
|
|
ldap.provider_label
|
|
.clone()
|
|
.unwrap_or_else(|| ldap.provider_name.clone())
|
|
}),
|
|
ldap_provider_icon_url: ldap.and_then(|ldap| ldap.provider_icon_url.clone()),
|
|
self_registration_enabled: state.config.security.allow_self_registration,
|
|
};
|
|
|
|
Ok(Json(ApiResponse::new(response)))
|
|
}
|
|
|
|
/// Login endpoint
|
|
///
|
|
/// POST /auth/login
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/auth/login",
|
|
tag = "auth",
|
|
request_body = LoginRequest,
|
|
responses(
|
|
(status = 200, description = "Successfully logged in", body = inline(ApiResponse<TokenResponse>)),
|
|
(status = 401, description = "Invalid credentials"),
|
|
(status = 400, description = "Validation error")
|
|
)
|
|
)]
|
|
pub async fn login(
|
|
State(state): State<SharedState>,
|
|
Json(payload): Json<LoginRequest>,
|
|
) -> Result<Json<ApiResponse<TokenResponse>>, ApiError> {
|
|
// Validate request
|
|
payload
|
|
.validate()
|
|
.map_err(|e| ApiError::ValidationError(format!("Invalid login request: {}", e)))?;
|
|
|
|
// Find identity by login
|
|
let identity = IdentityRepository::find_by_login(&state.db, &payload.login)
|
|
.await?
|
|
.ok_or_else(|| ApiError::Unauthorized("Invalid login or password".to_string()))?;
|
|
|
|
if identity.frozen {
|
|
return Err(ApiError::Forbidden(
|
|
"Identity is frozen and cannot authenticate".to_string(),
|
|
));
|
|
}
|
|
|
|
// Check if identity has a password set
|
|
let password_hash = identity
|
|
.password_hash
|
|
.as_ref()
|
|
.ok_or_else(|| ApiError::Unauthorized("Invalid login or password".to_string()))?;
|
|
|
|
// Verify password
|
|
let is_valid = verify_password(&payload.password, password_hash)
|
|
.map_err(|_| ApiError::Unauthorized("Invalid login or password".to_string()))?;
|
|
|
|
if !is_valid {
|
|
return Err(ApiError::Unauthorized(
|
|
"Invalid login or password".to_string(),
|
|
));
|
|
}
|
|
|
|
// Generate tokens
|
|
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
|
|
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
|
|
|
|
let response = TokenResponse::new(
|
|
access_token,
|
|
refresh_token,
|
|
state.jwt_config.access_token_expiration,
|
|
)
|
|
.with_user(
|
|
identity.id,
|
|
identity.login.clone(),
|
|
identity.display_name.clone(),
|
|
);
|
|
|
|
Ok(Json(ApiResponse::new(response)))
|
|
}
|
|
|
|
/// Register endpoint
|
|
///
|
|
/// POST /auth/register
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/auth/register",
|
|
tag = "auth",
|
|
request_body = RegisterRequest,
|
|
responses(
|
|
(status = 200, description = "Successfully registered", body = inline(ApiResponse<TokenResponse>)),
|
|
(status = 409, description = "User already exists"),
|
|
(status = 400, description = "Validation error")
|
|
)
|
|
)]
|
|
pub async fn register(
|
|
State(state): State<SharedState>,
|
|
Json(payload): Json<RegisterRequest>,
|
|
) -> Result<Json<ApiResponse<TokenResponse>>, ApiError> {
|
|
if !state.config.security.allow_self_registration {
|
|
return Err(ApiError::Forbidden(
|
|
"Self-service registration is disabled; identities must be provisioned by an administrator or identity provider".to_string(),
|
|
));
|
|
}
|
|
|
|
// Validate request
|
|
payload
|
|
.validate()
|
|
.map_err(|e| ApiError::ValidationError(format!("Invalid registration request: {}", e)))?;
|
|
|
|
// Check if login already exists
|
|
if IdentityRepository::find_by_login(&state.db, &payload.login)
|
|
.await?
|
|
.is_some()
|
|
{
|
|
return Err(ApiError::Conflict(format!(
|
|
"Identity with login '{}' already exists",
|
|
payload.login
|
|
)));
|
|
}
|
|
|
|
// Hash password
|
|
let password_hash = hash_password(&payload.password)?;
|
|
|
|
// Registration creates an identity only; permission assignments are managed separately.
|
|
let input = CreateIdentityInput {
|
|
login: payload.login.clone(),
|
|
display_name: payload.display_name,
|
|
password_hash: Some(password_hash),
|
|
attributes: serde_json::json!({}),
|
|
};
|
|
|
|
let identity = IdentityRepository::create(&state.db, input).await?;
|
|
|
|
// Generate tokens
|
|
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
|
|
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
|
|
|
|
let response = TokenResponse::new(
|
|
access_token,
|
|
refresh_token,
|
|
state.jwt_config.access_token_expiration,
|
|
)
|
|
.with_user(
|
|
identity.id,
|
|
identity.login.clone(),
|
|
identity.display_name.clone(),
|
|
);
|
|
|
|
Ok(Json(ApiResponse::new(response)))
|
|
}
|
|
|
|
/// Refresh token endpoint
|
|
///
|
|
/// POST /auth/refresh
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/auth/refresh",
|
|
tag = "auth",
|
|
request_body = RefreshTokenRequest,
|
|
responses(
|
|
(status = 200, description = "Successfully refreshed token", body = inline(ApiResponse<TokenResponse>)),
|
|
(status = 401, description = "Invalid or expired refresh token"),
|
|
(status = 400, description = "Validation error")
|
|
)
|
|
)]
|
|
pub async fn refresh_token(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
payload: Option<Json<RefreshTokenRequest>>,
|
|
) -> Result<Response, ApiError> {
|
|
let browser_cookie_refresh = payload.is_none();
|
|
let refresh_token = if let Some(Json(payload)) = payload {
|
|
payload.validate().map_err(|e| {
|
|
ApiError::ValidationError(format!("Invalid refresh token request: {}", e))
|
|
})?;
|
|
payload.refresh_token
|
|
} else {
|
|
get_cookie_value(&headers, REFRESH_COOKIE_NAME)
|
|
.ok_or_else(|| ApiError::Unauthorized("Missing refresh token".to_string()))?
|
|
};
|
|
|
|
// Validate refresh token
|
|
let claims = validate_token(&refresh_token, &state.jwt_config)
|
|
.map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".to_string()))?;
|
|
|
|
// Ensure it's a refresh token
|
|
if claims.token_type != TokenType::Refresh {
|
|
return Err(ApiError::Unauthorized("Invalid token type".to_string()));
|
|
}
|
|
|
|
// Parse identity ID
|
|
let identity_id: i64 = claims
|
|
.sub
|
|
.parse()
|
|
.map_err(|_| ApiError::Unauthorized("Invalid token".to_string()))?;
|
|
|
|
// Verify identity still exists
|
|
let identity = IdentityRepository::find_by_id(&state.db, identity_id)
|
|
.await?
|
|
.ok_or_else(|| ApiError::Unauthorized("Identity not found".to_string()))?;
|
|
|
|
if identity.frozen {
|
|
return Err(ApiError::Forbidden(
|
|
"Identity is frozen and cannot authenticate".to_string(),
|
|
));
|
|
}
|
|
|
|
// Generate new tokens
|
|
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
|
|
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
|
|
|
|
let response = TokenResponse::new(
|
|
access_token,
|
|
refresh_token,
|
|
state.jwt_config.access_token_expiration,
|
|
);
|
|
let response_body = Json(ApiResponse::new(response.clone()));
|
|
|
|
if browser_cookie_refresh {
|
|
let mut http_response = response_body.into_response();
|
|
apply_cookies_to_headers(
|
|
http_response.headers_mut(),
|
|
&crate::auth::oidc::build_auth_cookies(&state, &response, ""),
|
|
)?;
|
|
return Ok(http_response);
|
|
}
|
|
|
|
Ok(response_body.into_response())
|
|
}
|
|
|
|
/// Get current user endpoint
|
|
///
|
|
/// GET /auth/me
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/auth/me",
|
|
tag = "auth",
|
|
responses(
|
|
(status = 200, description = "Current user information", body = inline(ApiResponse<CurrentUserResponse>)),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "Identity not found")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
)
|
|
)]
|
|
pub async fn get_current_user(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
user: Result<RequireAuth, crate::auth::middleware::AuthError>,
|
|
) -> Result<Json<ApiResponse<CurrentUserResponse>>, ApiError> {
|
|
let authenticated_user = match user {
|
|
Ok(RequireAuth(user)) => user,
|
|
Err(_) => cookie_authenticated_user(&headers, &state)?
|
|
.ok_or_else(|| ApiError::Unauthorized("Unauthorized".to_string()))?,
|
|
};
|
|
let identity_id = authenticated_user.identity_id()?;
|
|
|
|
// Fetch identity from database
|
|
let identity = IdentityRepository::find_by_id(&state.db, identity_id)
|
|
.await?
|
|
.ok_or_else(|| ApiError::NotFound("Identity not found".to_string()))?;
|
|
|
|
if identity.frozen {
|
|
return Err(ApiError::Forbidden(
|
|
"Identity is frozen and cannot authenticate".to_string(),
|
|
));
|
|
}
|
|
|
|
let response = CurrentUserResponse {
|
|
id: identity.id,
|
|
login: identity.login,
|
|
display_name: identity.display_name,
|
|
};
|
|
|
|
Ok(Json(ApiResponse::new(response)))
|
|
}
|
|
|
|
/// Request body for LDAP login.
|
|
#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)]
|
|
pub struct LdapLoginRequest {
|
|
/// User login name (uid, sAMAccountName, etc.)
|
|
#[validate(length(min = 1, max = 255))]
|
|
pub login: String,
|
|
/// User password
|
|
#[validate(length(min = 1, max = 512))]
|
|
pub password: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct OidcLoginParams {
|
|
pub redirect_to: Option<String>,
|
|
}
|
|
|
|
/// Begin browser OIDC login by redirecting to the provider.
|
|
pub async fn oidc_login(
|
|
State(state): State<SharedState>,
|
|
Query(params): Query<OidcLoginParams>,
|
|
) -> Result<Response, ApiError> {
|
|
let login_redirect = build_login_redirect(&state, params.redirect_to.as_deref()).await?;
|
|
let mut response = Redirect::temporary(&login_redirect.authorization_url).into_response();
|
|
apply_cookies_to_headers(response.headers_mut(), &login_redirect.cookies)?;
|
|
Ok(response)
|
|
}
|
|
|
|
/// Handle the OIDC authorization code callback.
|
|
pub async fn oidc_callback(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<OidcCallbackQuery>,
|
|
) -> Result<Response, ApiError> {
|
|
let redirect_to = get_cookie_value(&headers, crate::auth::oidc::OIDC_REDIRECT_COOKIE_NAME);
|
|
let authenticated = crate::auth::oidc::handle_callback(&state, &headers, &query).await?;
|
|
oidc_callback_redirect_response(
|
|
&state,
|
|
&authenticated.token_response,
|
|
redirect_to,
|
|
&authenticated.id_token,
|
|
)
|
|
}
|
|
|
|
/// Authenticate via LDAP directory.
|
|
///
|
|
/// POST /auth/ldap/login
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/auth/ldap/login",
|
|
tag = "auth",
|
|
request_body = LdapLoginRequest,
|
|
responses(
|
|
(status = 200, description = "Successfully authenticated via LDAP", body = inline(ApiResponse<TokenResponse>)),
|
|
(status = 401, description = "Invalid LDAP credentials"),
|
|
(status = 501, description = "LDAP not configured")
|
|
)
|
|
)]
|
|
pub async fn ldap_login(
|
|
State(state): State<SharedState>,
|
|
Json(payload): Json<LdapLoginRequest>,
|
|
) -> Result<Json<ApiResponse<TokenResponse>>, ApiError> {
|
|
payload
|
|
.validate()
|
|
.map_err(|e| ApiError::ValidationError(format!("Invalid LDAP login request: {e}")))?;
|
|
|
|
let authenticated =
|
|
crate::auth::ldap::authenticate(&state, &payload.login, &payload.password).await?;
|
|
|
|
Ok(Json(ApiResponse::new(authenticated.token_response)))
|
|
}
|
|
|
|
/// Logout the current browser session and optionally redirect through the provider logout flow.
|
|
pub async fn logout(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
) -> Result<Response, ApiError> {
|
|
let oidc_enabled = state
|
|
.config
|
|
.security
|
|
.oidc
|
|
.as_ref()
|
|
.is_some_and(|oidc| oidc.enabled);
|
|
|
|
let response = if oidc_enabled {
|
|
let logout_redirect = build_logout_redirect(&state, &headers).await?;
|
|
let mut response = Redirect::temporary(&logout_redirect.redirect_url).into_response();
|
|
apply_cookies_to_headers(response.headers_mut(), &logout_redirect.cookies)?;
|
|
response
|
|
} else {
|
|
let mut response = Redirect::temporary("/login").into_response();
|
|
apply_cookies_to_headers(
|
|
response.headers_mut(),
|
|
&crate::auth::oidc::clear_auth_cookies(&state),
|
|
)?;
|
|
response
|
|
};
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
/// Change password endpoint
|
|
///
|
|
/// POST /auth/change-password
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/auth/change-password",
|
|
tag = "auth",
|
|
request_body = ChangePasswordRequest,
|
|
responses(
|
|
(status = 200, description = "Password changed successfully", body = inline(ApiResponse<SuccessResponse>)),
|
|
(status = 401, description = "Invalid current password or unauthorized"),
|
|
(status = 400, description = "Validation error"),
|
|
(status = 404, description = "Identity not found")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
)
|
|
)]
|
|
pub async fn change_password(
|
|
State(state): State<SharedState>,
|
|
RequireAuth(user): RequireAuth,
|
|
Json(payload): Json<ChangePasswordRequest>,
|
|
) -> Result<Json<ApiResponse<SuccessResponse>>, ApiError> {
|
|
// Validate request
|
|
payload.validate().map_err(|e| {
|
|
ApiError::ValidationError(format!("Invalid change password request: {}", e))
|
|
})?;
|
|
|
|
let identity_id = user.identity_id()?;
|
|
|
|
// Fetch identity from database
|
|
let identity = IdentityRepository::find_by_id(&state.db, identity_id)
|
|
.await?
|
|
.ok_or_else(|| ApiError::NotFound("Identity not found".to_string()))?;
|
|
|
|
// Get current password hash
|
|
let current_password_hash = identity
|
|
.password_hash
|
|
.as_ref()
|
|
.ok_or_else(|| ApiError::Unauthorized("No password set".to_string()))?;
|
|
|
|
// Verify current password
|
|
let is_valid = verify_password(&payload.current_password, current_password_hash)
|
|
.map_err(|_| ApiError::Unauthorized("Invalid current password".to_string()))?;
|
|
|
|
if !is_valid {
|
|
return Err(ApiError::Unauthorized(
|
|
"Invalid current password".to_string(),
|
|
));
|
|
}
|
|
|
|
// Hash new password
|
|
let new_password_hash = hash_password(&payload.new_password)?;
|
|
|
|
// Update identity in database with new password hash
|
|
use attune_common::repositories::identity::UpdateIdentityInput;
|
|
use attune_common::repositories::Update;
|
|
|
|
let update_input = UpdateIdentityInput {
|
|
display_name: None,
|
|
password_hash: Some(new_password_hash),
|
|
attributes: None,
|
|
frozen: None,
|
|
};
|
|
|
|
IdentityRepository::update(&state.db, identity_id, update_input).await?;
|
|
|
|
Ok(Json(ApiResponse::new(SuccessResponse::new(
|
|
"Password changed successfully",
|
|
))))
|
|
}
|
|
|
|
/// Create sensor token endpoint (internal use by sensor service)
|
|
///
|
|
/// POST /auth/sensor-token
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/auth/sensor-token",
|
|
tag = "auth",
|
|
request_body = CreateSensorTokenRequest,
|
|
responses(
|
|
(status = 200, description = "Sensor token created successfully", body = inline(ApiResponse<SensorTokenResponse>)),
|
|
(status = 400, description = "Validation error"),
|
|
(status = 401, description = "Unauthorized")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
)
|
|
)]
|
|
pub async fn create_sensor_token(
|
|
State(state): State<SharedState>,
|
|
RequireAuth(_user): RequireAuth,
|
|
Json(payload): Json<CreateSensorTokenRequest>,
|
|
) -> Result<Json<ApiResponse<SensorTokenResponse>>, ApiError> {
|
|
create_sensor_token_impl(state, payload).await
|
|
}
|
|
|
|
/// Create sensor token endpoint for internal service use (no auth required)
|
|
///
|
|
/// POST /auth/internal/sensor-token
|
|
///
|
|
/// This endpoint is intended for internal use by the sensor service to provision
|
|
/// tokens for standalone sensors. In production, this should be restricted by
|
|
/// network policies or replaced with proper service-to-service authentication.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/auth/internal/sensor-token",
|
|
tag = "auth",
|
|
request_body = CreateSensorTokenRequest,
|
|
responses(
|
|
(status = 200, description = "Sensor token created successfully", body = inline(ApiResponse<SensorTokenResponse>)),
|
|
(status = 400, description = "Validation error")
|
|
)
|
|
)]
|
|
pub async fn create_sensor_token_internal(
|
|
State(state): State<SharedState>,
|
|
Json(payload): Json<CreateSensorTokenRequest>,
|
|
) -> Result<Json<ApiResponse<SensorTokenResponse>>, ApiError> {
|
|
create_sensor_token_impl(state, payload).await
|
|
}
|
|
|
|
/// Shared implementation for sensor token creation
|
|
async fn create_sensor_token_impl(
|
|
state: SharedState,
|
|
payload: CreateSensorTokenRequest,
|
|
) -> Result<Json<ApiResponse<SensorTokenResponse>>, ApiError> {
|
|
// Validate request
|
|
payload
|
|
.validate()
|
|
.map_err(|e| ApiError::ValidationError(format!("Invalid sensor token request: {}", e)))?;
|
|
|
|
// Create or find sensor identity
|
|
let sensor_login = format!("sensor:{}", payload.sensor_ref);
|
|
|
|
let identity = match IdentityRepository::find_by_login(&state.db, &sensor_login).await? {
|
|
Some(identity) => identity,
|
|
None => {
|
|
// Create new sensor identity
|
|
let input = CreateIdentityInput {
|
|
login: sensor_login.clone(),
|
|
display_name: Some(format!("Sensor: {}", payload.sensor_ref)),
|
|
password_hash: None, // Sensors don't use passwords
|
|
attributes: serde_json::json!({
|
|
"type": "sensor",
|
|
"sensor_ref": payload.sensor_ref,
|
|
"trigger_types": payload.trigger_types,
|
|
}),
|
|
};
|
|
IdentityRepository::create(&state.db, input).await?
|
|
}
|
|
};
|
|
|
|
// Generate sensor token
|
|
let ttl_seconds = payload.ttl_seconds.unwrap_or(86400); // Default: 24 hours
|
|
let token = generate_sensor_token(
|
|
identity.id,
|
|
&payload.sensor_ref,
|
|
payload.trigger_types.clone(),
|
|
&state.jwt_config,
|
|
Some(ttl_seconds),
|
|
)?;
|
|
|
|
// Calculate expiration time
|
|
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(ttl_seconds);
|
|
|
|
let response = SensorTokenResponse {
|
|
identity_id: identity.id,
|
|
sensor_ref: payload.sensor_ref,
|
|
token,
|
|
expires_at: expires_at.to_rfc3339(),
|
|
trigger_types: payload.trigger_types,
|
|
};
|
|
|
|
Ok(Json(ApiResponse::new(response)))
|
|
}
|