use std::fs;
use crate::scaffold::{
add_to_controllers, add_to_models, add_to_tests, check_app, refuse_existing, write,
};
const CREATE_USERS: &str = include_str!("../templates/auth/create_users.glacier");
const CREATE_SESSIONS: &str = include_str!("../templates/auth/create_sessions.glacier");
const USER: &str = include_str!("../templates/auth/user.glacier");
const SESSION: &str = include_str!("../templates/auth/session.glacier");
const USER_FACTORY: &str = include_str!("../templates/auth/user_factory.glacier");
const USER_TEST: &str = include_str!("../templates/auth/user.test.glacier");
const USER_ACCESS: &str = include_str!("../templates/auth/user_access.glacier");
struct Flavour {
auth: &'static str,
authentication: &'static str,
require_authentication: &'static str,
sessions: &'static str,
registrations: &'static str,
sessions_test: &'static str,
registrations_test: &'static str,
}
const BROWSER: Flavour = Flavour {
auth: include_str!("../templates/auth/auth.glacier"),
authentication: include_str!("../templates/auth/authentication.glacier"),
require_authentication: include_str!("../templates/auth/require_authentication.glacier"),
sessions: include_str!("../templates/auth/sessions_controller.glacier"),
registrations: include_str!("../templates/auth/registrations_controller.glacier"),
sessions_test: include_str!("../templates/auth/sessions_controller.test.glacier"),
registrations_test: include_str!("../templates/auth/registrations_controller.test.glacier"),
};
const API: Flavour = Flavour {
auth: include_str!("../templates/auth/auth_api.glacier"),
authentication: include_str!("../templates/auth/authentication_api.glacier"),
require_authentication: include_str!("../templates/auth/require_authentication_api.glacier"),
sessions: include_str!("../templates/auth/sessions_controller_api.glacier"),
registrations: include_str!("../templates/auth/registrations_controller_api.glacier"),
sessions_test: include_str!("../templates/auth/sessions_controller_api.test.glacier"),
registrations_test: include_str!("../templates/auth/registrations_controller_api.test.glacier"),
};
const SESSIONS_NEW: &str = include_str!("../templates/auth/sessions_new.html");
const PASSWORDS: &str = include_str!("../templates/auth/passwords_controller.glacier");
const PASSWORDS_TEST: &str = include_str!("../templates/auth/passwords_controller.test.glacier");
const PASSWORDS_MAILER: &str = include_str!("../templates/auth/passwords_mailer.glacier");
const PASSWORDS_NEW: &str = include_str!("../templates/auth/passwords_new.html");
const PASSWORDS_EDIT: &str = include_str!("../templates/auth/passwords_edit.html");
const RESET_HTML: &str = include_str!("../templates/auth/passwords_mailer_reset.html");
const RESET_TEXT: &str = include_str!("../templates/auth/passwords_mailer_reset.txt");
const VERIFICATIONS: &str =
include_str!("../templates/auth/email_verifications_controller.glacier");
const VERIFICATIONS_TEST: &str =
include_str!("../templates/auth/email_verifications_controller.test.glacier");
const VERIFICATIONS_MAILER: &str =
include_str!("../templates/auth/email_verifications_mailer.glacier");
const VERIFICATIONS_NEW: &str = include_str!("../templates/auth/email_verifications_new.html");
const VERIFY_HTML: &str = include_str!("../templates/auth/email_verifications_mailer_verify.html");
const VERIFY_TEXT: &str = include_str!("../templates/auth/email_verifications_mailer_verify.txt");
const REQUIRE_VERIFIED: &str = include_str!("../templates/auth/require_verified.glacier");
const CONFIRMATIONS: &str =
include_str!("../templates/auth/password_confirmations_controller.glacier");
const CONFIRMATIONS_TEST: &str =
include_str!("../templates/auth/password_confirmations_controller.test.glacier");
const CONFIRMATIONS_NEW: &str = include_str!("../templates/auth/password_confirmations_new.html");
const REQUIRE_RECENT_PASSWORD: &str =
include_str!("../templates/auth/require_recent_password.glacier");
const SIGN_IN_LINKS: &str = include_str!("../templates/auth/sign_in_links_controller.glacier");
const SIGN_IN_LINKS_TEST: &str =
include_str!("../templates/auth/sign_in_links_controller.test.glacier");
const SIGN_IN_LINKS_MAILER: &str = include_str!("../templates/auth/sign_in_links_mailer.glacier");
const SIGN_IN_LINKS_SHOW: &str = include_str!("../templates/auth/sign_in_links_show.html");
const SIGN_IN_HTML: &str = include_str!("../templates/auth/sign_in_links_mailer_sign_in.html");
const SIGN_IN_TEXT: &str = include_str!("../templates/auth/sign_in_links_mailer_sign_in.txt");
const REGISTRATIONS_NEW: &str = include_str!("../templates/auth/registrations_new.html");
const EMAIL_CHANGES: &str = include_str!("../templates/auth/email_changes_controller.glacier");
const EMAIL_CHANGES_TEST: &str =
include_str!("../templates/auth/email_changes_controller.test.glacier");
const EMAIL_CHANGES_MAILER: &str = include_str!("../templates/auth/email_changes_mailer.glacier");
const EMAIL_CHANGES_NEW: &str = include_str!("../templates/auth/email_changes_new.html");
const EMAIL_CHANGE_MAILS: [(&str, &str); 4] = [
(
"confirm.txt",
include_str!("../templates/auth/email_changes_mailer_confirm.txt"),
),
(
"confirm.html",
include_str!("../templates/auth/email_changes_mailer_confirm.html"),
),
(
"changed.txt",
include_str!("../templates/auth/email_changes_mailer_changed.txt"),
),
(
"changed.html",
include_str!("../templates/auth/email_changes_mailer_changed.html"),
),
];
const PRUNE_SESSIONS: &str = include_str!("../templates/auth/prune_sessions.glacier");
const RECOVERY_CODE: &str = include_str!("../templates/auth/recovery_code.glacier");
const CREATE_RECOVERY_CODES: &str = include_str!("../templates/auth/create_recovery_codes.glacier");
const TWO_FACTORS: &str = include_str!("../templates/auth/two_factors_controller.glacier");
const TWO_FACTORS_TEST: &str =
include_str!("../templates/auth/two_factors_controller.test.glacier");
const TWO_FACTORS_NEW: &str = include_str!("../templates/auth/two_factors_new.html");
const RECOVERY_CODES: &str = include_str!("../templates/auth/two_factors_recovery_codes.html");
const CHALLENGES: &str = include_str!("../templates/auth/two_factor_challenges_controller.glacier");
const CHALLENGES_NEW: &str = include_str!("../templates/auth/two_factor_challenges_new.html");
const ADD_STAFF: &str = include_str!("../templates/auth/add_staff_to_users.glacier");
const IMPERSONATION: &str = include_str!("../templates/auth/impersonation.glacier");
const IMPERSONATION_HELPER: &str = include_str!("../templates/auth/impersonation_helper.glacier");
const IMPERSONATIONS: &str = include_str!("../templates/auth/impersonations_controller.glacier");
const IMPERSONATIONS_TEST: &str =
include_str!("../templates/auth/impersonations_controller.test.glacier");
const CREATE_API_TOKENS: &str = include_str!("../templates/auth/create_api_tokens.glacier");
const API_TOKEN: &str = include_str!("../templates/auth/api_token.glacier");
const API_TOKENS: &str = include_str!("../templates/auth/api_tokens_controller.glacier");
const API_TOKENS_TEST: &str = include_str!("../templates/auth/api_tokens_controller.test.glacier");
const API_TOKENS_INDEX: &str = include_str!("../templates/auth/api_tokens_index.html");
const API_TOKENS_NEW: &str = include_str!("../templates/auth/api_tokens_new.html");
#[derive(Debug, Clone, Copy, Default)]
pub struct Extras {
pub impersonation: bool,
pub api_tokens: bool,
}
pub fn authentication(api: bool, extras: Extras) -> Result<(), String> {
check_app()?;
refuse_existing(&[
"src/models/user.glacier",
"src/models/user.rs",
"src/models/session.glacier",
"src/models/session.rs",
"src/authentication.rs",
"src/auth.glacier",
"src/access/user_access.glacier",
"src/middleware/authentication.glacier",
"src/middleware/require_authentication.glacier",
"src/controllers/sessions_controller.glacier",
"src/controllers/registrations_controller.glacier",
"src/controllers/passwords_controller.glacier",
"src/mailers/passwords_mailer.glacier",
])?;
let api = api || crate::scaffold::api_app();
let flavour = if api { &API } else { &BROWSER };
if (extras.impersonation || extras.api_tokens)
&& (api
|| !std::path::Path::new(crate::scaffold::ROUTES_GLACIER).exists()
|| !crate::scaffold::discovers())
{
return Err(
"`--impersonation` and `--api-tokens` are for an app with pages, \
made with `glacier::app!()` and src/routes.glacier"
.into(),
);
}
crate::generate::migration_with("create_users", CREATE_USERS)?;
crate::generate::migration_with("create_sessions", CREATE_SESSIONS)?;
crate::generate::migration_with("create_recovery_codes", CREATE_RECOVERY_CODES)?;
let user = if extras.impersonation {
USER.replace(
" has_many :sessions\n",
" # Staff can sign in as another user (`ImpersonationsController`).\n \
field staff: bool, protected\n has_many :sessions\n",
)
} else {
USER.to_owned()
};
write("src/models/user.glacier", &user)?;
write("src/models/session.glacier", SESSION)?;
write("src/models/recovery_code.glacier", RECOVERY_CODE)?;
add_to_models("recovery_code")?;
add_to_models("session")?;
add_to_models("user")?;
if crate::scaffold::discovers() {
write("src/jobs/prune_sessions.glacier", PRUNE_SESSIONS)?;
if !crate::scaffold::has_jobs_migration()? {
crate::generate::migration_with(
"create_glacier_jobs",
crate::scaffold::JOBS_MIGRATION,
)?;
}
}
let resets = !api && std::path::Path::new(crate::scaffold::ROUTES_GLACIER).exists();
let (auth, registrations) = if api || resets {
(flavour.auth.to_owned(), flavour.registrations.to_owned())
} else {
(
without_verification(flavour.auth),
flavour
.registrations
.replace(" Auth.send_email_verification(&user)!\n", "")
.replace(" Check your email to confirm your address.", ""),
)
};
let auth = if extras.impersonation {
with_impersonation(&auth)
} else {
auth
};
write("src/auth.glacier", &auth)?;
write("src/access/user_access.glacier", USER_ACCESS)?;
crate::scaffold::declare_folder(
"access",
"Who requests are for: every .glacier (or .rs) file in this folder",
)?;
write(
"src/middleware/authentication.glacier",
&authentication_middleware(flavour.authentication, extras),
)?;
write(
"src/middleware/require_authentication.glacier",
flavour.require_authentication,
)?;
let sessions = if api || resets {
flavour.sessions.to_owned()
} else {
without_two_factor(flavour.sessions)
};
write("src/controllers/sessions_controller.glacier", &sessions)?;
write(
"src/controllers/registrations_controller.glacier",
®istrations,
)?;
add_to_controllers("registrations")?;
add_to_controllers("sessions")?;
if !api {
let sign_in = if resets {
SESSIONS_NEW.to_owned()
} else {
without_sign_in_links(&SESSIONS_NEW.replace(
"\n<p>{{ link_to(\"Forgot your password?\", new_password_path()) }}</p>",
"",
))
};
write("src/views/sessions/new.html", &sign_in)?;
write("src/views/registrations/new.html", REGISTRATIONS_NEW)?;
}
if resets {
mail_from()?;
write("src/controllers/passwords_controller.glacier", PASSWORDS)?;
write("src/mailers/passwords_mailer.glacier", PASSWORDS_MAILER)?;
write("src/views/passwords/new.html", PASSWORDS_NEW)?;
write("src/views/passwords/edit.html", PASSWORDS_EDIT)?;
write("src/views/passwords_mailer/reset.html", RESET_HTML)?;
write("src/views/passwords_mailer/reset.txt", RESET_TEXT)?;
write(
"tests/controllers/passwords_controller.test.glacier",
PASSWORDS_TEST,
)?;
write(
"src/controllers/email_verifications_controller.glacier",
VERIFICATIONS,
)?;
write(
"src/mailers/email_verifications_mailer.glacier",
VERIFICATIONS_MAILER,
)?;
write("src/views/email_verifications/new.html", VERIFICATIONS_NEW)?;
write(
"src/views/email_verifications_mailer/verify.html",
VERIFY_HTML,
)?;
write(
"src/views/email_verifications_mailer/verify.txt",
VERIFY_TEXT,
)?;
write("src/middleware/require_verified.glacier", REQUIRE_VERIFIED)?;
write(
"src/controllers/password_confirmations_controller.glacier",
CONFIRMATIONS,
)?;
write(
"src/views/password_confirmations/new.html",
CONFIRMATIONS_NEW,
)?;
write(
"src/middleware/require_recent_password.glacier",
REQUIRE_RECENT_PASSWORD,
)?;
write(
"tests/controllers/password_confirmations_controller.test.glacier",
CONFIRMATIONS_TEST,
)?;
write(
"src/controllers/sign_in_links_controller.glacier",
SIGN_IN_LINKS,
)?;
write(
"src/mailers/sign_in_links_mailer.glacier",
SIGN_IN_LINKS_MAILER,
)?;
write("src/views/sign_in_links/show.html", SIGN_IN_LINKS_SHOW)?;
write("src/views/sign_in_links_mailer/sign_in.html", SIGN_IN_HTML)?;
write("src/views/sign_in_links_mailer/sign_in.txt", SIGN_IN_TEXT)?;
write(
"tests/controllers/sign_in_links_controller.test.glacier",
SIGN_IN_LINKS_TEST,
)?;
write(
"tests/controllers/email_verifications_controller.test.glacier",
VERIFICATIONS_TEST,
)?;
write(
"src/controllers/email_changes_controller.glacier",
EMAIL_CHANGES,
)?;
write(
"src/mailers/email_changes_mailer.glacier",
EMAIL_CHANGES_MAILER,
)?;
write("src/views/email_changes/new.html", EMAIL_CHANGES_NEW)?;
for (name, mail) in EMAIL_CHANGE_MAILS {
write(&format!("src/views/email_changes_mailer/{name}"), mail)?;
}
write(
"tests/controllers/email_changes_controller.test.glacier",
EMAIL_CHANGES_TEST,
)?;
add_app_name(&display_name(&crate::scaffold::crate_name()?))?;
write(
"src/controllers/two_factors_controller.glacier",
TWO_FACTORS,
)?;
write(
"src/controllers/two_factor_challenges_controller.glacier",
CHALLENGES,
)?;
write("src/views/two_factors/new.html", TWO_FACTORS_NEW)?;
write("src/views/two_factors/recovery_codes.html", RECOVERY_CODES)?;
write("src/views/two_factor_challenges/new.html", CHALLENGES_NEW)?;
write(
"tests/controllers/two_factors_controller.test.glacier",
TWO_FACTORS_TEST,
)?;
}
if extras.impersonation {
impersonation()?;
}
if extras.api_tokens {
api_tokens()?;
}
write("tests/factories/user.glacier", USER_FACTORY)?;
write("tests/models/user.test.glacier", USER_TEST)?;
write(
"tests/controllers/sessions_controller.test.glacier",
flavour.sessions_test,
)?;
write(
"tests/controllers/registrations_controller.test.glacier",
flavour.registrations_test,
)?;
add_to_tests(&[
"controllers/registrations_controller",
"controllers/sessions_controller",
"factories/user",
"models/user",
])?;
register_module()?;
add_routes(api)?;
add_extra_routes(extras)?;
let protect = if api { "api" } else { "browser" };
println!(
"\nNext: glacier db migrate. Routes for signed-in users go in a scope\n\
through the `authenticated` pipeline:\n\n \
scope \"/\", through: [:{protect}, :authenticated] do\n \
get \"/account\", AccountsController.show, as: :account\n end\n\n\
and `Auth.current_user(&ctx)` (with `alias Auth`) is who's signed in."
);
if resets {
println!(
"Link to /email_change/new and /two_factor/new from a page of the\n\
user's account to let them change their email address and turn on\n\
two-factor sign-in."
);
}
if extras.impersonation {
println!(
"Staff (users whose `staff` column is true) sign in as another user\n\
with a form posting `user_id` to /impersonation."
);
}
if extras.api_tokens {
println!(
"Users make API tokens at /api_tokens; an action checks a token's\n\
scope with `glacier::auth::require_scope(&ctx, \"write\")?`."
);
}
Ok(())
}
fn impersonation() -> Result<(), String> {
crate::generate::migration_with("add_staff_to_users", ADD_STAFF)?;
write("src/impersonation.glacier", IMPERSONATION)?;
write(
"src/helpers/impersonation_helper.glacier",
IMPERSONATION_HELPER,
)?;
write(
"src/controllers/impersonations_controller.glacier",
IMPERSONATIONS,
)?;
write(
"tests/controllers/impersonations_controller.test.glacier",
IMPERSONATIONS_TEST,
)?;
let path = "src/views/layouts/app.html";
let banner = " {%- if let Some(impersonation) = impersonation() %}\n \
<p class=\"impersonating\">\n \
{{ impersonation.staff_email }}, you're signed in as {{ impersonation.user_email }}.\n \
{{ form_tag(\"/impersonation\", \"delete\") }}<button>Stop</button></form>\n \
</p>\n {%- end %}\n";
match fs::read_to_string(path) {
Ok(layout) if !layout.contains("impersonation()") && layout.contains(" <body>\n") => {
let updated = layout.replacen(" <body>\n", &format!(" <body>\n{banner}"), 1);
fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
}
_ => println!("\nShow staff who they're signed in as, in {path}:\n\n{banner}"),
}
Ok(())
}
fn api_tokens() -> Result<(), String> {
crate::generate::migration_with("create_api_tokens", CREATE_API_TOKENS)?;
write("src/models/api_token.glacier", API_TOKEN)?;
add_to_models("api_token")?;
write("src/controllers/api_tokens_controller.glacier", API_TOKENS)?;
write("src/views/api_tokens/index.html", API_TOKENS_INDEX)?;
write("src/views/api_tokens/new.html", API_TOKENS_NEW)?;
write(
"tests/controllers/api_tokens_controller.test.glacier",
API_TOKENS_TEST,
)?;
Ok(())
}
const IMPERSONATION_FUNCTIONS: &str =
" # Signs a staff member in as `user`, until they stop: they see what the
# user sees, and act as them.
def impersonate(ctx: &Context, user: &User) do
ctx.session().insert(\"impersonating_id\", user.id)?
end
def stop_impersonating(ctx: &Context) do
ctx.session().remove(\"impersonating_id\")
end
# The user a signed-in staff member is impersonating, if any; noted for
# the request (`Impersonation`). Never another staff member.
def impersonated(ctx: &Context, staff: &User) -> Option<User> do
user_id = ctx.session().get::<i64>(\"impersonating_id\").filter(fn _ -> staff.staff end)
found = if let Some(user_id) = user_id do
User.find_by(id: user_id)!.filter(fn user -> !user.staff end)
else
None
end
if let Some(user) = &found do
glacier::current::set(Impersonation { staff_id: staff.id, staff_email: staff.email_address.clone(), user_email: user.email_address.clone() })
end
found
end
";
fn with_impersonation(auth: &str) -> String {
let mut auth = auth
.replacen(
" ctx.session().remove(\"second_factor_user_id\")\n token\n",
" ctx.session().remove(\"second_factor_user_id\")\n \
ctx.session().remove(\"impersonating_id\")\n token\n",
1,
)
.replacen(
" ctx.session().remove(\"session_token\")\n end\n",
" ctx.session().remove(\"session_token\")\n \
ctx.session().remove(\"impersonating_id\")\n end\n",
1,
);
if let Some(at) = auth.find(" # Signs the user out everywhere") {
auth.insert_str(at, IMPERSONATION_FUNCTIONS);
}
auth
}
const SESSION_LOOKUP: &str = " if let Some(signed_in) = Session.find_by_token(&token)! do
if let Some(user) = signed_in.user.get().cloned() do
ctx.set_access(UserAccess { user: user.clone() })
ctx.extensions_mut().insert(user)
ctx.extensions_mut().insert(signed_in)
end
end
";
const IMPERSONATED_LOOKUP: &str = " if let Some(signed_in) = Session.find_by_token(&token)! do
if let Some(signed_in_user) = signed_in.user.get().cloned() do
# A staff member signed in as someone else acts as them.
user = Auth.impersonated(&ctx, &signed_in_user)!.unwrap_or(signed_in_user)
ctx.set_access(UserAccess { user: user.clone() })
ctx.extensions_mut().insert(user)
ctx.extensions_mut().insert(signed_in)
end
end
";
fn authentication_middleware(template: &str, extras: Extras) -> String {
if !extras.impersonation && !extras.api_tokens {
return template.to_owned();
}
let mut lookup = if extras.impersonation {
IMPERSONATED_LOOKUP.to_owned()
} else {
SESSION_LOOKUP.to_owned()
};
if extras.api_tokens {
let indented: String = lookup.lines().map(|line| format!(" {line}\n")).collect();
lookup = format!(
" if token.starts_with(\"glc_\") do
# An API token: its user, limited to what its scopes allow.
if let Some(api_token) = ApiToken.find_by_token(&token)! do
if let Some(user) = api_token.user.get().cloned() do
glacier::auth::limit_to_scopes(&mut ctx, api_token.scope_list())
ctx.set_access(UserAccess {{ user: user.clone() }})
ctx.extensions_mut().insert(user)
end
end
else
{indented} end
"
);
}
let mut out = template.replacen(SESSION_LOOKUP, &lookup, 1);
if extras.impersonation {
out = out.replacen(
" alias Access.UserAccess\n",
" alias Access.UserAccess\n alias Auth\n",
1,
);
}
out
}
fn add_extra_routes(extras: Extras) -> Result<(), String> {
let mut scope = String::new();
if extras.impersonation {
scope.push_str(
"\n # Staff signing in as another user, after their password again;\n \
# stopping needs neither.\n \
scope \"/\", through: [:browser, :authenticated, :sudo] do\n \
post \"/impersonation\", ImpersonationsController.create, as: :impersonation\n end\n \
scope \"/\", through: [:browser, :authenticated] do\n \
delete \"/impersonation\", ImpersonationsController.destroy\n end\n",
);
}
if extras.api_tokens {
scope.push_str(
"\n # A user's API tokens, after their password again.\n \
scope \"/\", through: [:browser, :authenticated, :sudo] do\n \
get \"/api_tokens\", ApiTokensController.index, as: :api_tokens\n \
get \"/api_tokens/new\", ApiTokensController.new, as: :new_api_token\n \
post \"/api_tokens\", ApiTokensController.create\n \
delete \"/api_tokens/:id\", ApiTokensController.destroy, as: :api_token\n end\n",
);
}
if scope.is_empty() {
return Ok(());
}
let path = crate::scaffold::ROUTES_GLACIER;
let routes = fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
match routes.rfind("\nend").map(|at| at + 1) {
Some(close) => {
let mut updated = routes.clone();
updated.insert_str(close, &scope);
fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
}
None => println!("\nAdd these to the routes in {path}:\n{scope}"),
}
Ok(())
}
fn add_app_name(name: &str) -> Result<(), String> {
let path = "config/default.toml";
let Ok(config) = fs::read_to_string(path) else {
return Ok(());
};
if config
.lines()
.any(|line| line.trim_start().starts_with("name ="))
{
return Ok(());
}
let line = format!(
"# The app's name, as people see it (an authenticator app shows it beside\n# two-factor codes).\nname = \"{name}\"\n\n"
);
let at = config.find("\n[").map_or(config.len(), |i| i + 1);
let mut updated = config.clone();
updated.insert_str(at, &line);
fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
Ok(())
}
pub(crate) fn display_name(crate_name: &str) -> String {
crate_name
.split('_')
.filter(|w| !w.is_empty())
.map(|w| {
let mut chars = w.chars();
chars
.next()
.map(|c| c.to_uppercase().chain(chars).collect::<String>())
.unwrap_or_default()
})
.collect::<Vec<_>>()
.join(" ")
}
fn without_two_factor(sessions: &str) -> String {
sessions.replace(
" if user.two_factor() do
# The code from their app comes next.
Auth.begin_second_factor(&ctx, &user, true)?
redirect new_two_factor_challenge_path()
else
Auth.start_session(&ctx, &user)!
Auth.password_entered(&ctx)?
redirect Auth.after_sign_in_path(&ctx), notice: \"Signed in.\"
end
",
" Auth.start_session(&ctx, &user)!
Auth.password_entered(&ctx)?
redirect Auth.after_sign_in_path(&ctx), notice: \"Signed in.\"
",
)
}
fn without_sign_in_links(page: &str) -> String {
match (
page.find("{{ form_tag(sign_in_links_path()"),
page.find("<p>New here?"),
) {
(Some(start), Some(end)) => format!("{}{}", &page[..start], &page[end..]),
_ => page.to_owned(),
}
}
fn without_verification(auth: &str) -> String {
let mut auth = auth.replace(" alias Mailers.EmailVerificationsMailer\n", "");
if let (Some(start), Some(end)) = (
auth.find(" # Mails the user a link confirming their email address."),
auth.find(" # Signs the user out everywhere"),
) {
auth.replace_range(start..end, "");
}
auth
}
fn mail_from() -> Result<(), String> {
let path = "config/default.toml";
let config = fs::read_to_string(path).unwrap_or_default();
if config.contains("[mail]") {
return Ok(());
}
let mut updated = config.trim_end().to_owned();
updated.push_str(
"\n\n[mail]\n# Who the app's mail is from, such as its password reset mail.\n\
# Change it to an address of yours.\nfrom = \"noreply@example.com\"\n",
);
fs::write(path, updated.trim_start()).map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
Ok(())
}
const THROTTLE_FUNCTIONS: &str = " # The address someone is signing in with, when they are.
def sign_in_email(ctx: &Context) -> Option<String> do
signing_in = *ctx.method() == Method.POST and ctx.path() == \"/session\"
email = ctx.params().get(\"email_address\")?.trim().to_lowercase()
signing_in.then_some(email)
end
# The address a request would mail a link to, when it would: the
# signed-in user's own, or the one in the form.
def mailed_to(ctx: &Context) -> Option<String> do
mails = *ctx.method() == Method.POST and [\"/passwords\", \"/sign_in_links\", \"/email_verification\", \"/email_change\"].contains(&ctx.path())
signed_in = Auth.current_user(ctx).map(fn user -> user.email_address.clone() end)
email = signed_in.or_else(fn -> ctx.params().get(\"email_address\").map(str::to_owned) end)?
mails.then_some(email.trim().to_lowercase())
end
# The user entering a two-factor code, when someone is.
def second_factor_attempt(ctx: &Context) -> Option<String> do
attempting = *ctx.method() == Method.POST and ctx.path() == \"/two_factor_challenge\"
user_id = ctx.session().get::<i64>(\"second_factor_user_id\")?
attempting.then_some(user_id.to_string())
end
";
fn add_throttles() -> Result<(), String> {
let path = "src/app.glacier";
let Ok(app) = fs::read_to_string(path) else {
return Ok(());
};
if app.contains("def sign_in_email") {
return Ok(());
}
let mut lines: Vec<String> = app.lines().map(str::to_owned).collect();
let aliases = [
"alias glacier.middleware.Throttle",
"alias std.time.Duration",
"alias Auth",
];
let after = lines
.iter()
.rposition(|l| l.starts_with(" alias "))
.or_else(|| lines.iter().position(|l| l.trim() == "app do"))
.ok_or_else(|| format!("{path} has no `app do`"))?;
let missing: Vec<String> = aliases
.iter()
.filter(|a| !lines.iter().any(|l| l.trim() == **a))
.map(|a| format!(" {a}"))
.collect();
lines.splice(after + 1..after + 1, missing);
let end = lines
.iter()
.rposition(|l| l.trim() == "end")
.ok_or_else(|| format!("{path} has no `end`"))?;
let mut functions = vec![String::new()];
functions.extend(THROTTLE_FUNCTIONS.lines().map(str::to_owned));
lines.splice(end..end, functions);
let settings = lines
.iter()
.rposition(|l| l.starts_with(" middleware "))
.map_or(after + 1, |at| at + 1);
let throttles = [
" # At most 5 sign-in attempts a minute for each email address.",
" middleware Throttle.new(\"sign-in\", 5, Duration.from_secs(60), sign_in_email)",
" # At most 3 mailed links (password resets, sign-in links and email",
" # confirmations) every 15 minutes for each address.",
" middleware Throttle.new(\"mailed-links\", 3, Duration.from_secs(15 * 60), mailed_to)",
" # At most 5 two-factor codes a minute for each user signing in.",
" middleware Throttle.new(\"two-factor\", 5, Duration.from_secs(60), second_factor_attempt)",
];
lines.splice(settings..settings, throttles.map(str::to_owned));
fs::write(path, lines.join("\n") + "\n").map_err(|e| format!("writing {path}: {e}"))?;
Ok(())
}
fn register_module() -> Result<(), String> {
if crate::scaffold::discovers() {
crate::scaffold::add_app_setting(
"middleware Authentication",
"Who's signed in, for every request.",
)?;
add_throttles()?;
return Ok(());
}
let path = "src/lib.rs";
let lib = fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
let mut updated = lib.clone();
for declare in ["glacier::modules!(auth);\n", "pub mod middleware;\n"] {
if !updated.contains(declare) {
updated = match updated.find("pub mod controllers;") {
Some(at) => format!("{}{declare}{}", &updated[..at], &updated[at..]),
None => format!("{declare}{updated}"),
};
}
}
let verified = std::path::Path::new("src/middleware/require_verified.glacier").exists();
if std::path::Path::new("src/middleware/mod.rs").exists() {
crate::scaffold::add_to_list("src/middleware/mod.rs", "middleware", "authentication")?;
crate::scaffold::add_to_list(
"src/middleware/mod.rs",
"middleware",
"require_authentication",
)?;
if verified {
crate::scaffold::add_to_list(
"src/middleware/mod.rs",
"middleware",
"require_verified",
)?;
crate::scaffold::add_to_list(
"src/middleware/mod.rs",
"middleware",
"require_recent_password",
)?;
}
} else if verified {
crate::scaffold::write(
"src/middleware/mod.rs",
"//! The app's own middleware, from src/middleware/<name>.glacier (or .rs).\n\
glacier::middleware!(authentication, require_authentication, require_verified, require_recent_password);\n",
)?;
} else {
crate::scaffold::write(
"src/middleware/mod.rs",
"//! The app's own middleware, from src/middleware/<name>.glacier (or .rs).\n\
glacier::middleware!(authentication, require_authentication);\n",
)?;
}
let middleware = " // Who's signed in, for every request.\n \
.middleware(middleware::Authentication)\n";
if !updated.contains("middleware::Authentication") {
let anchor = " .middleware(Logger)\n";
match updated.find(anchor) {
Some(at) => updated.insert_str(at + anchor.len(), middleware),
None => println!(
"\nRegister the middleware on your App in {path}:\n\n \
.middleware(middleware::Authentication)"
),
}
}
if updated != lib {
fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
}
Ok(())
}
fn add_routes(api: bool) -> Result<(), String> {
if std::path::Path::new(crate::scaffold::ROUTES_GLACIER).exists() {
return add_glacier_routes(api);
}
let path = "src/routes.rs";
let routes = fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
let mut updated = routes.clone();
let import = "use crate::middleware::RequireAuthentication;\n";
if !updated.contains(import) {
let anchor = "use crate::controllers::*;\n";
updated = match updated.find(anchor) {
Some(at) => format!("{}{import}{}", &updated[..at], &updated[at..]),
None => format!("{import}{updated}"),
};
}
let mut pipelines = String::new();
if api && !updated.contains("pipeline api ") {
pipelines.push_str(" // JSON APIs: errors as JSON, no CSRF (clients send tokens).\n");
pipelines.push_str(" pipeline api = Pipeline::api();\n");
}
pipelines.push_str(
" // Only for signed-in users; see src/middleware/require_authentication.glacier.\n",
);
pipelines
.push_str(" pipeline authenticated = Pipeline::new().with(RequireAuthentication);\n");
let scope = if api {
"\n scope \"/\" through api {\n \
post \"/session\" => Sessions::create, as session;\n \
delete \"/session\" => Sessions::destroy;\n \
post \"/registration\" => Registrations::create, as registration;\n }\n"
} else {
"\n scope \"/\" through browser {\n \
get \"/session/new\" => Sessions::new, as new_session;\n \
post \"/session\" => Sessions::create, as session;\n \
delete \"/session\" => Sessions::destroy;\n \
get \"/registration/new\" => Registrations::new, as new_registration;\n \
post \"/registration\" => Registrations::create, as registration;\n }\n"
};
let pipelines_at = updated
.rfind(" pipeline ")
.and_then(|at| updated[at..].find('\n').map(|end| at + end + 1));
let close = updated.rfind("\n}").map(|at| at + 1);
match (pipelines_at, close) {
(Some(pipelines_at), Some(close)) if pipelines_at < close => {
updated.insert_str(close, scope);
updated.insert_str(pipelines_at, &pipelines);
}
_ => {
println!("\nAdd these to the routes! table in {path}:\n\n{pipelines}{scope}");
}
}
if updated != routes {
fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
}
Ok(())
}
fn add_glacier_routes(api: bool) -> Result<(), String> {
let path = crate::scaffold::ROUTES_GLACIER;
let routes = fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
let mut updated = routes.clone();
let alias = if api {
" alias Middleware.RequireAuthentication\n"
} else {
" alias Middleware.RequireAuthentication\n alias Middleware.RequireVerified\n alias Middleware.RequireRecentPassword\n"
};
if !updated.contains(alias) {
updated = match updated.find("routes do\n") {
Some(at) => {
let after = at + "routes do\n".len();
format!("{}{alias}\n{}", &updated[..after], &updated[after..])
}
None => format!("{alias}{updated}"),
};
}
let mut pipelines = String::new();
if api && !updated.contains("pipeline api ") {
pipelines.push_str(" # JSON APIs: errors as JSON, no CSRF (clients send tokens).\n");
pipelines.push_str(" pipeline api = Pipeline.api()\n");
}
pipelines.push_str(
" # Only for signed-in users; see src/middleware/require_authentication.glacier.\n",
);
pipelines.push_str(" pipeline authenticated = Pipeline.new().with(RequireAuthentication)\n");
if !api {
pipelines.push_str(
" # Only users who've confirmed their email address, after `authenticated`;\n \
# see src/middleware/require_verified.glacier.\n",
);
pipelines.push_str(" pipeline verified = Pipeline.new().with(RequireVerified)\n");
pipelines.push_str(
" # Only if the password was entered in the last 15 minutes, after\n \
# `authenticated`; see src/middleware/require_recent_password.glacier.\n",
);
pipelines.push_str(" pipeline sudo = Pipeline.new().with(RequireRecentPassword)\n");
}
let scope = if api {
"\n scope \"/\", through: :api do\n \
post \"/session\", SessionsController.create, as: :session\n \
delete \"/session\", SessionsController.destroy\n \
post \"/registration\", RegistrationsController.create, as: :registration\n end\n"
} else {
"\n scope \"/\", through: :browser do\n \
get \"/session/new\", SessionsController.new, as: :new_session\n \
post \"/session\", SessionsController.create, as: :session\n \
delete \"/session\", SessionsController.destroy\n \
get \"/registration/new\", RegistrationsController.new, as: :new_registration\n \
post \"/registration\", RegistrationsController.create, as: :registration\n \
get \"/passwords/new\", PasswordsController.new, as: :new_password\n \
post \"/passwords\", PasswordsController.create, as: :passwords\n \
get \"/passwords/:token/edit\", PasswordsController.edit, as: :edit_password\n \
patch \"/passwords/:token\", PasswordsController.update, as: :password\n \
get \"/email_verifications/:token\", EmailVerificationsController.show, as: :email_verification\n \
get \"/email_changes/:token\", EmailChangesController.show, as: :email_change\n \
post \"/sign_in_links\", SignInLinksController.create, as: :sign_in_links\n \
get \"/sign_in_links/:token\", SignInLinksController.show, as: :sign_in_link\n \
post \"/sign_in_links/:token\", SignInLinksController.update\n \
get \"/two_factor_challenge/new\", TwoFactorChallengesController.new, as: :new_two_factor_challenge\n \
post \"/two_factor_challenge\", TwoFactorChallengesController.create, as: :two_factor_challenge\n end\n\n \
scope \"/\", through: [:browser, :authenticated] do\n \
get \"/email_verification/new\", EmailVerificationsController.new, as: :new_email_verification\n \
post \"/email_verification\", EmailVerificationsController.create, as: :email_verifications\n \
get \"/password_confirmation/new\", PasswordConfirmationsController.new, as: :new_password_confirmation\n \
post \"/password_confirmation\", PasswordConfirmationsController.create, as: :password_confirmations\n end\n\n \
# Changing the email address, and turning two-factor sign-in on\n \
# and off, ask for the password again.\n \
scope \"/\", through: [:browser, :authenticated, :sudo] do\n \
get \"/email_change/new\", EmailChangesController.new, as: :new_email_change\n \
post \"/email_change\", EmailChangesController.create, as: :email_changes\n \
get \"/two_factor/new\", TwoFactorsController.new, as: :new_two_factor\n \
post \"/two_factor\", TwoFactorsController.create, as: :two_factors\n \
delete \"/two_factor\", TwoFactorsController.destroy\n end\n"
};
let pipelines_at = updated
.rfind(" pipeline ")
.and_then(|at| updated[at..].find('\n').map(|end| at + end + 1));
let close = updated.rfind("\nend").map(|at| at + 1);
match (pipelines_at, close) {
(Some(pipelines_at), Some(close)) if pipelines_at < close => {
updated.insert_str(close, scope);
updated.insert_str(pipelines_at, &pipelines);
}
_ => println!("\nAdd these to the routes in {path}:\n\n{pipelines}{scope}"),
}
if updated != routes {
fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
}
Ok(())
}