Files
attune/crates/api/src/routes/auth.rs

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)))
}