//! Authentication routes use axum::{ extract::State, 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, verify_password, }, dto::{ ApiResponse, 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, /// Optional TTL in seconds (default: 86400 = 24 hours, max: 259200 = 72 hours) #[validate(range(min = 3600, max = 259200))] pub ttl_seconds: Option, } /// 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, } /// Create authentication routes pub fn routes() -> Router { Router::new() .route("/login", post(login)) .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)) } /// 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)), (status = 401, description = "Invalid credentials"), (status = 400, description = "Validation error") ) )] pub async fn login( State(state): State, Json(payload): Json, ) -> Result>, 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()))?; // 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)), (status = 409, description = "User already exists"), (status = 400, description = "Validation error") ) )] pub async fn register( State(state): State, Json(payload): Json, ) -> Result>, ApiError> { // 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)?; // Create identity with password hash 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)), (status = 401, description = "Invalid or expired refresh token"), (status = 400, description = "Validation error") ) )] pub async fn refresh_token( State(state): State, Json(payload): Json, ) -> Result>, ApiError> { // Validate request payload .validate() .map_err(|e| ApiError::ValidationError(format!("Invalid refresh token request: {}", e)))?; // Validate refresh token let claims = validate_token(&payload.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()))?; // 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, ); Ok(Json(ApiResponse::new(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)), (status = 401, description = "Unauthorized"), (status = 404, description = "Identity not found") ), security( ("bearer_auth" = []) ) )] pub async fn get_current_user( State(state): State, RequireAuth(user): RequireAuth, ) -> Result>, ApiError> { 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()))?; let response = CurrentUserResponse { id: identity.id, login: identity.login, display_name: identity.display_name, }; Ok(Json(ApiResponse::new(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)), (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, RequireAuth(user): RequireAuth, Json(payload): Json, ) -> Result>, 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, }; 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)), (status = 400, description = "Validation error"), (status = 401, description = "Unauthorized") ), security( ("bearer_auth" = []) ) )] pub async fn create_sensor_token( State(state): State, RequireAuth(_user): RequireAuth, Json(payload): Json, ) -> Result>, 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)), (status = 400, description = "Validation error") ) )] pub async fn create_sensor_token_internal( State(state): State, Json(payload): Json, ) -> Result>, 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>, 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))) }