//! Signing up, signing in, and signing out.
//!
//! Named `accounts` rather than `auth` so it does not read as the same thing
//! as [`crate::auth`], which holds the password hashing and the session
//! extraction these handlers lean on.
use std::sync::Arc;
use actix_session::Session;
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, get, post, web};
use sea_orm::{ColumnTrait, DatabaseConnection, DbErr, EntityTrait, PaginatorTrait, QueryFilter};
use serde::Deserialize;
use tokio::sync::RwLock;
use tracing::error;
use uuid::Uuid;
use crate::auth::{CurrentUser, log_in, log_out, verify_password, viewer_name};
use crate::entities::prelude::Users;
use crate::entities::users;
use crate::registration::{RegisterError, Registration};
use crate::views::{LoginForm, RegisterForm};
use crate::{AppState, state_read, views};
/// What the registration form posts.
#[derive(Debug, Deserialize)]
pub struct Register {
pub name: String,
pub email: String,
pub password: String,
pub invite: Option<String>,
}
/// What the `?invite=` query on the register page carries.
#[derive(Debug, Deserialize)]
pub struct RegisterQuery {
pub invite: Option<String>,
}
/// What the login form posts.
#[derive(Debug, Deserialize)]
pub struct Login {
name: String,
password: String,
}
#[get("/register")]
#[tracing::instrument(skip_all)]
pub(crate) async fn get_register(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
query: web::Query<RegisterQuery>,
) -> HttpResponse {
let state = state_read(&data).await;
let db = &state.db;
let mode = crate::registration::registration_mode();
// In invite-only mode with a non-empty DB, check whether the invite is
// acceptable before rendering the form.
if mode == crate::registration::RegistrationMode::InviteOnly {
let user_count = match Users::find().count(db).await {
Ok(c) => c,
Err(error) => {
error!(%error, "counting users to decide the register page failed");
return HttpResponse::InternalServerError().finish();
}
};
if user_count > 0 {
match query.invite.as_deref() {
None => {
let mut response = views::register_closed(
viewer_name(viewer.as_ref()),
"Registration on this server is invite-only. Ask an existing user for an invite link.",
);
response.head_mut().status = StatusCode::FORBIDDEN;
return response;
}
Some(raw) => {
let parsed = match raw.parse::<Uuid>() {
Ok(u) => u,
Err(_) => {
let mut response = views::register_closed(
viewer_name(viewer.as_ref()),
"This invite is not valid (any more).",
);
response.head_mut().status = StatusCode::FORBIDDEN;
return response;
}
};
if crate::registration::find_valid_invite(db, parsed)
.await
.is_none()
{
let mut response = views::register_closed(
viewer_name(viewer.as_ref()),
"This invite is not valid (any more).",
);
response.head_mut().status = StatusCode::FORBIDDEN;
return response;
}
}
}
}
}
views::register(
viewer_name(viewer.as_ref()),
&RegisterForm {
name: None,
email: None,
invite: query.invite.clone(),
error: None,
},
)
}
#[post("/register")]
#[tracing::instrument(skip_all)]
pub(crate) async fn post_register(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
session: Session,
form: web::Form<Register>,
) -> HttpResponse {
let form = form.into_inner();
let state = state_read(&data).await;
let db = &state.db;
// Parse the invite if present; a malformed value counts as "no valid
// invite", which the gate in `register()` will turn into the same 403 the
// missing-invite path produces.
let invite = form
.invite
.as_deref()
.filter(|s| !s.trim().is_empty())
.and_then(|s| s.parse::<Uuid>().ok());
let registration = Registration {
name: form.name.clone(),
email: form.email.clone(),
password: form.password.clone(),
ssh_key: None,
invite,
};
let user = match crate::registration::register(db, registration).await {
Ok(user) => user,
Err(error) => {
let message = match &error {
RegisterError::Invalid(reason) => reason.clone(),
RegisterError::Taken => "That name or email is already taken.".to_string(),
RegisterError::Database(_) => {
return HttpResponse::InternalServerError().finish();
}
RegisterError::InviteRequired => {
"Registration on this server is invite-only.".to_string()
}
RegisterError::InviteInvalid(reason) => reason.clone(),
};
let status = match error {
RegisterError::Invalid(_) => StatusCode::BAD_REQUEST,
RegisterError::Taken => StatusCode::CONFLICT,
RegisterError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
RegisterError::InviteRequired | RegisterError::InviteInvalid(_) => {
// Don't show the form when invite is required/invalid
let mut response =
views::register_closed(viewer_name(viewer.as_ref()), &message);
response.head_mut().status = StatusCode::FORBIDDEN;
return response;
}
};
return refused_register(viewer_name(viewer.as_ref()), form, &message, status);
}
};
if let Err(error) = log_in(&session, &user) {
error!(%error, user = %user.name, "starting a session failed");
return HttpResponse::InternalServerError().finish();
}
HttpResponse::SeeOther()
.append_header(("Location", format!("/~{}", user.name)))
.finish()
}
#[get("/login")]
#[tracing::instrument(skip_all)]
pub(crate) async fn get_login(viewer: Option<CurrentUser>) -> HttpResponse {
views::login(
viewer_name(viewer.as_ref()),
&LoginForm {
name: None,
error: None,
},
)
}
#[post("/login")]
#[tracing::instrument(skip_all)]
pub(crate) async fn post_login(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
session: Session,
form: web::Form<Login>,
) -> HttpResponse {
let form = form.into_inner();
let state = state_read(&data).await;
let db = &state.db;
let user = match find_by_name(db, &form.name).await {
Ok(user) => user,
Err(error) => {
error!(%error, name = %form.name, "looking up the user to log in failed");
return HttpResponse::InternalServerError().finish();
}
};
// A user that is not there and a password that does not match are the
// same answer on purpose: which of the two it was is not the caller's
// business. Neither is an error on our side, so the span stays green.
let Some(user) = user.filter(|user| verify_password(&form.password, &user.password_hash))
else {
let mut response = views::login(
viewer_name(viewer.as_ref()),
&LoginForm {
name: Some(form.name),
error: Some("Wrong name or password.".to_string()),
},
);
response.head_mut().status = StatusCode::UNAUTHORIZED;
return response;
};
if let Err(error) = log_in(&session, &user) {
error!(%error, user = %user.name, "starting a session failed");
return HttpResponse::InternalServerError().finish();
}
HttpResponse::SeeOther()
.append_header(("Location", format!("/~{}", user.name)))
.finish()
}
#[post("/logout")]
#[tracing::instrument(skip_all)]
pub(crate) async fn post_logout(session: Session) -> HttpResponse {
log_out(&session);
HttpResponse::SeeOther()
.append_header(("Location", "/"))
.finish()
}
/// Renders the registration form again, with what went wrong, under `status`.
fn refused_register(
viewer: Option<&str>,
form: Register,
message: &str,
status: StatusCode,
) -> HttpResponse {
let mut response = views::register(
viewer,
&RegisterForm {
name: Some(form.name),
email: Some(form.email),
invite: form.invite,
error: Some(message.to_string()),
},
);
response.head_mut().status = status;
response
}
#[tracing::instrument(level = "debug", skip(db))]
async fn find_by_name(db: &DatabaseConnection, name: &str) -> Result<Option<users::Model>, DbErr> {
Users::find()
.filter(users::Column::Name.eq(name))
.one(db)
.await
}