use actix_web::http::header;
use actix_web::{cookie, web, HttpRequest, HttpResponse};
use uuid::Uuid;
use crate::pages::templates::LOG_IN;
use crate::pages::{insert_security_headers, request_to_jar};
use crate::{DataBaseRo, DataBaseRw, WebData};
#[derive(serde_derive::Serialize)]
struct PageData {
csrf: Uuid,
}
#[derive(serde_derive::Deserialize)]
pub struct FormData {
login: String,
password: String,
csrf: Uuid,
}
#[derive(serde_derive::Deserialize)]
pub struct FormDataMastodon {
login: String,
csrf: Uuid,
}
pub async fn get_log_in(request: HttpRequest, data: web::Data<WebData<'_>>) -> HttpResponse {
let csrf = Uuid::new_v4();
{
let mut cache = data.cache_login.lock().await;
cache.insert(
csrf,
(),
std::time::Duration::from_secs(data.cache_duration_sec),
);
}
let jar = request_to_jar(&request);
if jar
.private(&data.cookies_key)
.get("i_accept_cookie")
.is_none_or(|x| x.value() != "yes")
{
return HttpResponse::Found()
.append_header((header::LOCATION, "cookies-policy.html"))
.finish();
}
let body = match data.handlebars.render(LOG_IN, &PageData { csrf }) {
Ok(b) => b,
Err(e) => {
log::error!("Render login error: {}", e);
return HttpResponse::ServiceUnavailable().body(actix_web::body::None::new());
}
};
insert_security_headers(HttpResponse::Ok()).body(body)
}
fn log_in_response(
player_name: &str,
request: &HttpRequest,
cookies_key: &actix_web::cookie::Key,
set_cookie_domain: bool,
base_domain: &str,
check_cookie: bool,
) -> HttpResponse {
let mut jar = request_to_jar(request);
if check_cookie
&& jar
.private(cookies_key)
.get("i_accept_cookie")
.is_none_or(|x| x.value() != "yes")
{
return HttpResponse::Found()
.append_header((header::LOCATION, "cookies-policy.html"))
.finish();
}
let mut builder = cookie::Cookie::build("auth", player_name.to_lowercase())
.path("/")
.secure(true)
.http_only(true)
.same_site(cookie::SameSite::Strict)
.max_age(cookie::time::Duration::weeks(4));
if set_cookie_domain {
builder = builder.domain(base_domain.to_string());
}
jar.private_mut(cookies_key).add(builder.finish());
let mut response = HttpResponse::Found()
.append_header((header::LOCATION, "my.html"))
.finish();
if let Some(c) = jar.get("auth") {
if let Err(e) = response.add_cookie(c) {
log::error!("Cann't set cookie {}", e);
}
}
response
}
pub async fn post_log_in(
request: HttpRequest,
form: web::Form<FormData>,
data: web::Data<WebData<'_>>,
data_ro: web::Data<DataBaseRo>,
) -> HttpResponse {
let cached_data = {
let mut cache = data.cache_login.lock().await;
cache.remove(&form.csrf)
};
if cached_data.is_none() {
log::warn!("Unknown data for CSRF: {}", form.csrf);
return HttpResponse::BadRequest().body("Incorrect");
}
let dbclient = match data_ro.0.get().await {
Ok(c) => c,
Err(e) => {
log::error!("Pool RO error {}", e);
return HttpResponse::ServiceUnavailable().body(actix_web::body::None::new());
}
};
let stmt = match dbclient.prepare("select web_password = crypt($1, web_password) from auth.users where player_name = $2 limit 1;").await {
Ok(stmt) => stmt,
Err(e) => {
log::error!("Pool RO statement error {}", e);
return HttpResponse::ServiceUnavailable().body(actix_web::body::None::new());
}
};
let rows = match dbclient
.query_opt(&stmt, &[&form.password, &form.login])
.await
{
Ok(rows) => rows,
Err(e) => {
log::error!("Pool RO query error {}", e);
return HttpResponse::ServiceUnavailable().body(actix_web::body::None::new());
}
};
let row = match rows {
Some(row) => row,
None => {
return HttpResponse::NotFound().body("Not found");
}
};
if !row.get::<_, Option<bool>>(0).unwrap_or(false) {
return HttpResponse::NotFound().body("Not found");
}
log_in_response(
&form.login,
&request,
&data.cookies_key,
data.set_cookie_domain,
&data.base_domain,
true,
)
}
fn get_mastodon_redirect_url(base_proto: &str, base_domain: &str, domain: &str) -> String {
format!(
"{}://{}/mastodon-redirect-{}.html",
base_proto, base_domain, domain
)
}
async fn register_in_mastodon(
domain: &str,
reg_sess_id: &Uuid,
data: &web::Data<WebData<'_>>,
) -> Result<(String, String), ()> {
log::info!(
"Mastodon domain {} registration session {} started",
domain,
reg_sess_id
);
let client = awc::Client::default();
let website = format!("{}://{}/", data.base_proto, data.base_domain);
let website_redirect = get_mastodon_redirect_url(&data.base_proto, &data.base_domain, domain);
match client
.post(format!("https://{}/api/v1/apps", domain))
.content_type("application/x-www-form-urlencoded")
.send_body(format!(
"client_name=FreeOrion%20Test%20Web&redirect_uris={}&scopes=read:accounts&website={}",
pct_str::PctString::encode(website_redirect.chars(), pct_str::URIReserved).as_str(),
pct_str::PctString::encode(website.chars(), pct_str::URIReserved).as_str()
))
.await
{
Ok(mut resp) => match resp.json::<serde_json::Value>().await {
Ok(serde_json::Value::Object(resp_value_obj)) => {
let client_id_value = resp_value_obj.get("client_id");
let client_secret_value = resp_value_obj.get("client_secret");
if let (
Some(serde_json::Value::String(client_id)),
Some(serde_json::Value::String(client_secret)),
) = (client_id_value, client_secret_value)
{
Ok((client_id.clone(), client_secret.clone()))
} else {
log::error!(
"Mastodon domain {} registration session {} missing fields JSON {:?}",
domain,
reg_sess_id,
resp_value_obj
);
Err(())
}
}
Ok(resp_value) => {
log::error!(
"Mastodon domain {} registration session {} non-object JSON {:?}",
domain,
reg_sess_id,
resp_value
);
Err(())
}
Err(err) => {
log::error!(
"Mastodon domain {} registration session {} failed JSON {:?}",
domain,
reg_sess_id,
err
);
Err(())
}
},
Err(err) => {
log::error!(
"Mastodon domain {} registration session {} failed {:?}",
domain,
reg_sess_id,
err
);
Err(())
}
}
}
async fn obtain_token_mastodon(
base_proto: &str,
base_domain: &str,
domain: &str,
client_id: &str,
client_secret: &str,
code: &str,
) -> Result<String, ()> {
let client = awc::Client::default();
let website_redirect = get_mastodon_redirect_url(base_proto, base_domain, domain);
match client
.post(format!("https://{}/oauth/token", domain))
.content_type("application/x-www-form-urlencoded")
.send_body(format!(
"client_id={}&client_secret={}&redirect_uri={}&grant_type=authorization_code&code={}&scopes=read:accounts",
pct_str::PctString::encode(client_id.chars(), pct_str::URIReserved).as_str(),
pct_str::PctString::encode(client_secret.chars(), pct_str::URIReserved).as_str(),
pct_str::PctString::encode(website_redirect.chars(), pct_str::URIReserved).as_str(),
pct_str::PctString::encode(code.chars(), pct_str::URIReserved).as_str()
))
.await {
Ok(mut resp) => match resp.json::<serde_json::Value>().await {
Ok(serde_json::Value::Object(resp_value_obj)) => {
let access_token_value = resp_value_obj.get("access_token");
if let Some(serde_json::Value::String(access_token)) = access_token_value {
Ok(access_token.clone())
} else {
log::error!(
"Mastodon domain {} login failed missing fields JSON {:?}",
domain,
resp_value_obj
);
Err(())
}
}
Ok(resp_value) => {
log::error!(
"Mastodon domain {} login failed non-object JSON {:?}",
domain,
resp_value
);
Err(())
}
Err(err) => {
log::error!(
"Mastodon domain {} login failed JSON {:?}",
domain,
err
);
Err(())
}
}
Err(err) => {
log::error!(
"Mastodon domain {} login failed {:?}",
domain,
err
);
Err(())
}
}
}
async fn check_token_mastodon(domain: &str, token: &str) -> Result<String, ()> {
let client = awc::Client::default();
match client
.get(format!(
"https://{}/api/v1/accounts/verify_credentials",
domain
))
.insert_header(("Authorization", format!("Bearer {}", token)))
.send()
.await
{
Ok(mut resp) => match resp.json::<serde_json::Value>().await {
Ok(serde_json::Value::Object(resp_value_obj)) => {
let username_value = resp_value_obj.get("username");
if let Some(serde_json::Value::String(username)) = username_value {
Ok(username.clone())
} else {
log::error!(
"Mastodon domain {} login failed missing fields JSON {:?}",
domain,
resp_value_obj
);
Err(())
}
}
Ok(resp_value) => {
log::error!(
"Mastodon domain {} login failed non-object JSON {:?}",
domain,
resp_value
);
Err(())
}
Err(err) => {
log::error!("Mastodon domain {} login failed JSON {:?}", domain, err);
Err(())
}
},
Err(err) => {
log::error!("Mastodon domain {} login failed {:?}", domain, err);
Err(())
}
}
}
async fn player_name_by_mastodon(
dbclient: &deadpool::managed::Object<deadpool_postgres::Manager>,
user: &str,
domain: &str,
) -> Result<Option<String>, ()> {
let stmt = match dbclient.prepare("select player_name from auth.contacts where protocol = 'mastodon' and is_active = true and delete_ts is null and address = $1 limit 1;").await {
Ok(stmt) => stmt,
Err(e) => {
log::error!("Pool RO statement error {}", e);
return Err(())
}
};
let address = format!("{}@{}", user, domain);
let rows = match dbclient.query_opt(&stmt, &[&address]).await {
Ok(rows) => rows,
Err(e) => {
log::error!("Pool RO query error {}", e);
return Err(());
}
};
let row = match rows {
Some(row) => row,
None => {
return Ok(None);
}
};
Ok(Some(row.get::<_, String>(0)))
}
pub async fn post_log_in_mastodon(
form: web::Form<FormDataMastodon>,
data: web::Data<WebData<'_>>,
data_ro: web::Data<DataBaseRo>,
data_rw: web::Data<DataBaseRw>,
) -> HttpResponse {
let cached_data = {
let mut cache = data.cache_login.lock().await;
cache.remove(&form.csrf)
};
if cached_data.is_none() {
log::warn!("Unknown data for CSRF: {}", form.csrf);
return HttpResponse::BadRequest().body("Incorrect");
}
let mut parts = form.login.splitn(4, '@');
let (user, domain) = match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(u), Some(d), None, None) | (Some(""), Some(u), Some(d), None) => {
(u.to_ascii_lowercase(), d.to_ascii_lowercase())
}
other => {
log::error!("Unknown Mastodon address {:?}", other);
return HttpResponse::NotFound().body("Not found");
}
};
let dbclient = match data_ro.0.get().await {
Ok(c) => c,
Err(e) => {
log::error!("Pool RO error {}", e);
return HttpResponse::ServiceUnavailable().body(actix_web::body::None::new());
}
};
let player_name = match player_name_by_mastodon(&dbclient, &user, &domain).await {
Ok(Some(player_name)) => player_name,
Ok(None) => {
return HttpResponse::NotFound().body("Not found");
}
Err(()) => {
return HttpResponse::ServiceUnavailable().body(actix_web::body::None::new());
}
};
log::info!("Found player {} to login with mastodon", player_name);
let dbclient_rw = match data_rw.0.get().await {
Ok(c) => c,
Err(e) => {
log::error!("Pool RW error {}", e);
return HttpResponse::ServiceUnavailable().body(actix_web::body::None::new());
}
};
let stmt = match dbclient_rw
.prepare(
"INSERT INTO auth.mastodon_apps (domain, refresh_ts, reg_sess_id) VALUES ($1, $2, $3)
ON CONFLICT (domain) DO UPDATE SET refresh_ts = $2
RETURNING client_id, client_secret, reg_sess_id;",
)
.await
{
Ok(stmt) => stmt,
Err(e) => {
log::error!("Pool RW statement error {}", e);
return HttpResponse::ServiceUnavailable().body(actix_web::body::None::new());
}
};
let stmt_save = match dbclient_rw
.prepare(
"UPDATE auth.mastodon_apps
SET client_id = $1
, client_secret = $2
WHERE client_id IS NULL
AND client_secret IS NULL
AND reg_sess_id = $3
AND domain = $4;",
)
.await
{
Ok(stmt) => stmt,
Err(e) => {
log::error!("Pool RW statement save error {}", e);
return HttpResponse::ServiceUnavailable().body(actix_web::body::None::new());
}
};
let ts = chrono::Utc::now().naive_utc();
let reg_sess_id = Uuid::new_v4();
log::info!(
"Mastodon domain {} registration session {} created",
domain,
reg_sess_id
);
let rows = match dbclient_rw
.query_opt(&stmt, &[&domain, &ts, ®_sess_id])
.await
{
Ok(rows) => rows,
Err(e) => {
log::error!("Pool RW query error {}", e);
return HttpResponse::ServiceUnavailable().body(actix_web::body::None::new());
}
};
let row = match rows {
Some(row) => row,
None => {
log::error!("Pool RW query error");
return HttpResponse::ServiceUnavailable().body(actix_web::body::None::new());
}
};
let opt_client_id = row.get::<_, Option<String>>(0);
let opt_client_secret = row.get::<_, Option<String>>(1);
let old_reg_sess_id = row.get::<_, uuid::Uuid>(2);
let (client_id, _client_secret) = if let (Some(client_id), Some(client_secret)) =
(opt_client_id, opt_client_secret)
{
(client_id, client_secret)
} else {
if reg_sess_id != old_reg_sess_id {
log::warn!(
"Mastodon domain {} registration session {} conflict with {}",
domain,
reg_sess_id,
old_reg_sess_id
);
return HttpResponse::Found()
.append_header((header::LOCATION, "index.html"))
.finish();
}
match register_in_mastodon(&domain, ®_sess_id, &data).await {
Ok(res) => {
let updated = match dbclient_rw
.execute(&stmt_save, &[&res.0, &res.1, ®_sess_id, &domain])
.await
{
Ok(c) => c,
Err(e) => {
log::error!("Pool RW execute update error {}", e);
return HttpResponse::ServiceUnavailable()
.body(actix_web::body::None::new());
}
};
if updated == 1 {
res
} else {
log::error!("Pool RW execute update row error {}", updated);
return HttpResponse::ServiceUnavailable().body(actix_web::body::None::new());
}
}
Err(_) => {
return HttpResponse::ServiceUnavailable().body(actix_web::body::None::new());
}
}
};
log::info!(
"Mastodon domain {} registration session {} completed with client_id {}",
domain,
reg_sess_id,
client_id
);
let state = Uuid::new_v4().to_string();
let website_redirect = get_mastodon_redirect_url(&data.base_proto, &data.base_domain, &domain);
let location = format!("https://{}/oauth/authorize?client_id={}&scope=read:accounts&redirect_uri={}&response_type=code&state={}",
domain,
pct_str::PctString::encode(client_id.chars(), pct_str::URIReserved).as_str(),
pct_str::PctString::encode(website_redirect.chars(), pct_str::URIReserved).as_str(),
state);
{
let mut cache = data.cache_mastodon_state.lock().await;
cache.insert(
state,
domain,
std::time::Duration::from_secs(data.cache_duration_sec),
);
}
HttpResponse::Found()
.append_header((header::LOCATION, location))
.finish()
}
#[actix_web::get("/mastodon-redirect-{domain}.html")]
pub async fn get_log_in_mastodon_redirect(
req: HttpRequest,
domain: web::Path<String>,
data: web::Data<WebData<'_>>,
data_ro: web::Data<DataBaseRo>,
) -> HttpResponse {
let domain = domain.into_inner();
let domain = match domain.char_indices().nth(128) {
None => domain,
Some((idx, _)) => domain[..idx].to_string(),
};
let mut code = None;
let mut state = None;
for params in req.query_string().split('&') {
if let Some((k, v)) = params.split_once('=') {
if k == "code" {
code = Some(v);
} else if k == "state" {
state = Some(v);
}
}
}
let (code, state) = match (code, state) {
(Some(c), Some(s)) => (c, s),
_ => {
log::warn!(
"Mastodon not redirected with code or state in {}",
req.query_string()
);
return HttpResponse::BadRequest().body("Incorrect query");
}
};
let cached_data = {
let mut cache = data.cache_mastodon_state.lock().await;
cache.remove(state)
};
if cached_data.is_none_or(|x| x != domain) {
log::warn!("Unknown state for mastodon redirect: {}", state);
return HttpResponse::BadRequest().body("Incorrect");
}
let dbclient = match data_ro.0.get().await {
Ok(client) => client,
Err(e) => {
log::error!("{}", e);
return HttpResponse::ServiceUnavailable().body(actix_web::body::None::new());
}
};
let stmt = match dbclient
.prepare("select client_id, client_secret from auth.mastodon_apps where domain = $1;")
.await
{
Ok(stmt) => stmt,
Err(e) => {
log::error!("{}", e);
return HttpResponse::ServiceUnavailable().body(actix_web::body::None::new());
}
};
let rows = match dbclient.query_opt(&stmt, &[&domain]).await {
Ok(rows) => rows,
Err(e) => {
log::error!("Pool RO query error {}", e);
return HttpResponse::ServiceUnavailable().body(actix_web::body::None::new());
}
};
let row = match rows {
Some(r) => r,
None => {
return HttpResponse::NotFound().body("Not found");
}
};
let opt_client_id = row.get::<_, Option<&str>>(0);
let opt_client_secret = row.get::<_, Option<&str>>(1);
if let (Some(client_id), Some(client_secret)) = (opt_client_id, opt_client_secret) {
match obtain_token_mastodon(
&data.base_proto,
&data.base_domain,
&domain,
client_id,
client_secret,
code,
)
.await
{
Ok(token) => match check_token_mastodon(&domain, &token).await {
Ok(user) => match player_name_by_mastodon(&dbclient, &user, &domain).await {
Ok(Some(player_name)) => log_in_response(
&player_name,
&req,
&data.cookies_key,
data.set_cookie_domain,
&data.base_domain,
false,
),
Ok(None) => HttpResponse::NotFound().body("Not found"),
Err(()) => {
HttpResponse::ServiceUnavailable().body(actix_web::body::None::new())
}
},
Err(()) => HttpResponse::NotFound().body("Not found"),
},
Err(()) => HttpResponse::NotFound().body("Not found"),
}
} else {
log::warn!("Missing mastodon client data for domain {}", domain);
HttpResponse::NotFound().body("Not found")
}
}