use std::fmt;
use reqwest::Method;
use secrecy::{ExposeSecret, SecretString};
use serde::Serialize;
use crate::{Client, Error, Result, client::AuthenticationScope};
#[derive(Clone)]
#[must_use]
pub struct LoginRequest {
username: String,
password: SecretString,
two_factor_code: Option<SecretString>,
}
impl LoginRequest {
pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
Self {
username: username.into(),
password: SecretString::from(password.into()),
two_factor_code: None,
}
}
pub fn with_two_factor_code(mut self, code: impl Into<String>) -> Self {
self.two_factor_code = Some(SecretString::from(code.into()));
self
}
pub fn username(&self) -> &str {
&self.username
}
}
impl fmt::Debug for LoginRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("LoginRequest")
.field("username", &self.username)
.field("password", &"[REDACTED]")
.field(
"two_factor_code",
&self.two_factor_code.as_ref().map(|_| "[REDACTED]"),
)
.finish()
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct LoginPayload<'a> {
username: &'a str,
password: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
two_factor_code: Option<&'a str>,
}
#[derive(Clone)]
pub struct CsrfToken(SecretString);
impl CsrfToken {
pub fn expose(&self) -> &str {
self.0.expose_secret()
}
}
impl fmt::Debug for CsrfToken {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("CsrfToken([REDACTED])")
}
}
#[derive(Clone, Copy, Debug)]
pub struct AuthApi<'client> {
client: &'client Client,
}
impl<'client> AuthApi<'client> {
pub(crate) const fn new(client: &'client Client) -> Self {
Self { client }
}
pub async fn csrf_token(self) -> Result<CsrfToken> {
self.client.ensure_csrf_token().await.map(CsrfToken)
}
pub async fn is_two_factor_enabled(self) -> Result<bool> {
let envelope = self
.client
.execute::<bool, ()>(
Method::POST,
"getTwoFactorEnable",
None,
AuthenticationScope::Session,
)
.await?;
let url = self.client.endpoint("getTwoFactorEnable")?;
envelope.obj.ok_or_else(|| Error::MissingObject {
method: Method::POST,
url: Box::new(url),
})
}
pub async fn login(self, credentials: LoginRequest) -> Result<()> {
let payload = LoginPayload {
username: credentials.username(),
password: credentials.password.expose_secret(),
two_factor_code: credentials
.two_factor_code
.as_ref()
.map(ExposeSecret::expose_secret),
};
self.client
.execute::<serde_json::Value, _>(
Method::POST,
"login",
Some(&payload),
AuthenticationScope::Session,
)
.await?;
Ok(())
}
pub async fn logout(self) -> Result<()> {
self.client
.execute::<serde_json::Value, ()>(
Method::POST,
"logout",
None,
AuthenticationScope::Session,
)
.await?;
self.client.clear_csrf_token().await;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_request_debug_redacts_all_secrets() {
let request =
LoginRequest::new("admin", "password-secret").with_two_factor_code("otp-secret");
let debug = format!("{request:?}");
assert!(debug.contains("admin"));
assert!(!debug.contains("password-secret"));
assert!(!debug.contains("otp-secret"));
}
#[test]
fn csrf_debug_is_redacted() {
let token = CsrfToken(SecretString::from("csrf-secret".to_owned()));
assert!(!format!("{token:?}").contains("csrf-secret"));
assert_eq!(token.expose(), "csrf-secret");
}
}