use actix_web::dev::Payload;
use actix_web::http::header::AUTHORIZATION;
use actix_web::{Error, FromRequest, HttpRequest, HttpResponse, error::InternalError, web};
use std::collections::HashMap;
use std::future::{Ready, ready};
use crate::config::Config;
use crate::session::SessionStore;
/// Extracts a PIN from an HTTP request.
/// Checks the following locations in order:
/// 1. `X-PIN` header
/// 2. `X-API-Key` header (fallback compatibility)
/// 3. `Authorization` header (`Bearer <pin>`, `Pin <pin>`, `ApiKey <pin>`, or raw `<pin>`)
/// 4. Query string `pin` or `PIN` (fallback: `api_key` or `apiKey`)
pub fn extract_pin(req: &HttpRequest) -> Option<String> {
// 1. Try X-PIN header (case-insensitive in Actix HeaderMap)
if let Some(val) = req.headers().get("X-PIN")
&& let Ok(s) = val.to_str()
{
let trimmed = s.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
// 2. Try X-API-Key header (for backwards compatibility if client still sends it)
if let Some(val) = req.headers().get("X-API-Key")
&& let Ok(s) = val.to_str()
{
let trimmed = s.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
// 3. Try Authorization header: Bearer <pin>, Pin <pin>, ApiKey <pin>, or raw <pin>
if let Some(val) = req.headers().get(AUTHORIZATION)
&& let Ok(s) = val.to_str()
{
let trimmed = s.trim();
if let Some(token) = trimmed
.strip_prefix("Bearer ")
.or_else(|| trimmed.strip_prefix("bearer "))
{
let t = token.trim();
if !t.is_empty() {
return Some(t.to_string());
}
} else if let Some(token) = trimmed
.strip_prefix("Pin ")
.or_else(|| trimmed.strip_prefix("pin "))
{
let t = token.trim();
if !t.is_empty() {
return Some(t.to_string());
}
} else if let Some(token) = trimmed
.strip_prefix("ApiKey ")
.or_else(|| trimmed.strip_prefix("apikey "))
{
let t = token.trim();
if !t.is_empty() {
return Some(t.to_string());
}
} else if !trimmed.is_empty() && !trimmed.contains(' ') {
return Some(trimmed.to_string());
}
}
// 4. Try query string: ?pin=... or ?PIN=... (or fallback ?api_key=... / ?apiKey=...)
if let Ok(query) = web::Query::<HashMap<String, String>>::from_query(req.query_string())
&& let Some(pin) = query
.get("pin")
.or_else(|| query.get("PIN"))
.or_else(|| query.get("api_key"))
.or_else(|| query.get("apiKey"))
{
let trimmed = pin.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
None
}
/// Extractor that requires a valid PIN (or active authenticated session).
/// If valid, the handler continues.
/// If missing or invalid, immediately responds with HTTP 401 Unauthorized.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PinAuth;
impl FromRequest for PinAuth {
type Error = Error;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
let is_valid_pin = if let Some(config) = req.app_data::<web::Data<Config>>() {
if let Some(pin) = extract_pin(req) {
config.is_valid_pin(&pin)
} else {
false
}
} else {
false
};
let is_valid_session =
if let Some(session_store) = req.app_data::<web::Data<SessionStore>>() {
session_store.is_authenticated(req)
} else {
false
};
if is_valid_pin || is_valid_session {
ready(Ok(PinAuth))
} else {
let resp = HttpResponse::Unauthorized()
.content_type("application/json")
.json(serde_json::json!({
"error": "Unauthorized",
"message": "Invalid or missing PIN"
}));
ready(Err(InternalError::from_response("", resp).into()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::http::header::HeaderValue;
use actix_web::test as aw_test;
fn test_config() -> Config {
Config::new(
8080,
HeaderValue::from_static(crate::config::DEFAULT_CACHE_CONTROL),
"notify.db".to_string(),
false,
"123456".to_string(),
)
}
#[test]
fn test_extract_pin_from_header() {
let req = aw_test::TestRequest::default()
.insert_header(("X-PIN", "123456"))
.to_http_request();
assert_eq!(extract_pin(&req), Some("123456".to_string()));
}
#[test]
fn test_extract_pin_from_legacy_header() {
let req = aw_test::TestRequest::default()
.insert_header(("X-API-Key", "123456"))
.to_http_request();
assert_eq!(extract_pin(&req), Some("123456".to_string()));
}
#[test]
fn test_extract_pin_from_authorization_bearer() {
let req = aw_test::TestRequest::default()
.insert_header(("Authorization", "Bearer 123456"))
.to_http_request();
assert_eq!(extract_pin(&req), Some("123456".to_string()));
}
#[test]
fn test_extract_pin_from_authorization_pin() {
let req = aw_test::TestRequest::default()
.insert_header(("Authorization", "Pin 123456"))
.to_http_request();
assert_eq!(extract_pin(&req), Some("123456".to_string()));
}
#[test]
fn test_extract_pin_from_authorization_apikey() {
let req = aw_test::TestRequest::default()
.insert_header(("Authorization", "ApiKey 123456"))
.to_http_request();
assert_eq!(extract_pin(&req), Some("123456".to_string()));
}
#[test]
fn test_extract_pin_from_authorization_raw() {
let req = aw_test::TestRequest::default()
.insert_header(("Authorization", "123456"))
.to_http_request();
assert_eq!(extract_pin(&req), Some("123456".to_string()));
}
#[test]
fn test_extract_pin_from_query() {
let req = aw_test::TestRequest::default()
.uri("/some/path?pin=123456")
.to_http_request();
assert_eq!(extract_pin(&req), Some("123456".to_string()));
let req2 = aw_test::TestRequest::default()
.uri("/some/path?PIN=123456")
.to_http_request();
assert_eq!(extract_pin(&req2), Some("123456".to_string()));
let req3 = aw_test::TestRequest::default()
.uri("/some/path?api_key=123456")
.to_http_request();
assert_eq!(extract_pin(&req3), Some("123456".to_string()));
}
#[test]
fn test_extract_pin_missing() {
let req = aw_test::TestRequest::default().to_http_request();
assert_eq!(extract_pin(&req), None);
}
#[actix_web::test]
async fn test_pin_auth_extractor_valid() {
let config = web::Data::new(test_config());
let req = aw_test::TestRequest::default()
.app_data(config)
.insert_header(("X-PIN", "123456"))
.to_http_request();
let mut payload = Payload::None;
let res = PinAuth::from_request(&req, &mut payload).await;
assert!(res.is_ok());
assert_eq!(res.unwrap(), PinAuth);
}
#[actix_web::test]
async fn test_pin_auth_extractor_invalid() {
let config = web::Data::new(test_config());
let req = aw_test::TestRequest::default()
.app_data(config)
.insert_header(("X-PIN", "9999"))
.to_http_request();
let mut payload = Payload::None;
let res = PinAuth::from_request(&req, &mut payload).await;
assert!(res.is_err());
}
#[actix_web::test]
async fn test_pin_auth_extractor_missing() {
let config = web::Data::new(test_config());
let req = aw_test::TestRequest::default()
.app_data(config)
.to_http_request();
let mut payload = Payload::None;
let res = PinAuth::from_request(&req, &mut payload).await;
assert!(res.is_err());
}
#[actix_web::test]
async fn test_pin_auth_extractor_session_valid() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let token = session_store.create_session().await;
let cookie =
actix_web::cookie::Cookie::build(crate::session::SESSION_COOKIE_NAME, token).finish();
let req = aw_test::TestRequest::default()
.app_data(config)
.app_data(session_store)
.cookie(cookie)
.to_http_request();
let mut payload = Payload::None;
let res = PinAuth::from_request(&req, &mut payload).await;
assert!(res.is_ok());
assert_eq!(res.unwrap(), PinAuth);
}
}