From 95362c8a996500050e2ccaee2bfc09d0785d7c45 Mon Sep 17 00:00:00 2001 From: Matthew Gordon Date: Mon, 26 Feb 2024 11:13:32 -0400 Subject: [PATCH] Setup and authentication WIP - Add page for creating intial admin user - Add JWT for authentication of logged-in users - Add tracing - Some improvements to error handling --- .cargo/config.toml | 1 + Cargo.toml | 14 +- src/admin.rs | 82 ++++++-- src/authentication/jwt.rs | 176 ++++++++++++++++++ .../mod.rs} | 33 ++-- src/config.rs | 2 + src/db/mod.rs | 142 +++++++++++--- src/error.rs | 54 ++++-- src/main.rs | 20 +- templates/admin/create_first_user.html | 2 +- templates/admin/index.html | 2 +- templates/base.html | 12 +- templates/error.html | 3 + 13 files changed, 462 insertions(+), 81 deletions(-) create mode 100644 src/authentication/jwt.rs rename src/{authentication.rs => authentication/mod.rs} (76%) create mode 100644 templates/error.html diff --git a/.cargo/config.toml b/.cargo/config.toml index f7da9a3..559c309 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,3 @@ [env] LOCALITY_STATIC_FILE_PATH = { value = "static", relative = true } +LOCALITY_HMAC_SECRET = "Not-secret testing secret" \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index a8299b1..b362573 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,19 @@ tokio = { version = "1", features = ["rt-multi-thread"]} axum = { version = "0.7", default_features = false, features = ["http1", "form", "tokio"] } askama = "0.12" askama_axum = "0.4" -tower-http = { version = "0.5", features = ["fs"] } +tower-http = { version = "0.5", features = ["fs", "trace"] } deadpool-postgres = { version = "0.12", features = ["rt_tokio_1"] } tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] } thiserror = "1.0" argon2 = { version = "0.5", features = ["password-hash", "std"] } -serde = { version = "1.0", features = ["derive"] } \ No newline at end of file +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +base64 = "0.21" +hmac = "0.12" +sha2 = "0.10" +digest = "0.10" +chrono = { version = "0.4.34", features = ["serde"] } +axum-extra = { version = "0.9", features = ["cookie"] } +http = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", default_features = false, features = ["std", "fmt", "ansi"] } diff --git a/src/admin.rs b/src/admin.rs index f86d238..f14c3c7 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -1,6 +1,9 @@ use { crate::{ - authentication::{authenticate_user, Password}, + authentication::{ + authenticate_user_with_jwt, authenticate_user_with_password, check_if_user_is_admin, + create_jwt_for_user, AuthenticatedAdminUser, ParsedJwt, Password, + }, db::Database, error::Error, }, @@ -8,16 +11,25 @@ use { askama_axum::{IntoResponse, Response}, axum::{ extract::State, + response::Redirect, routing::{get, post}, Form, Router, }, + axum_extra::extract::{ + cookie::{Cookie, SameSite}, + CookieJar, + }, serde::Deserialize, }; pub fn routes() -> Router { Router::new() .route("/", get(root)) - .route("/create_initial_admin_user", post(create_first_admin_user)) + .route("/create_first_admin_user", get(get_create_first_admin_user)) + .route( + "/create_first_admin_user", + post(post_create_first_admin_user), + ) } #[derive(Template)] @@ -30,27 +42,60 @@ struct FirstLoginTemplate {} #[derive(Template)] #[template(path = "admin/index.html")] -struct IndexTemplate {} +struct IndexTemplate<'a> { + admin_user_name: &'a str, +} -async fn root(State(db): State) -> Result { +async fn check_jwt(db: &Database, cookie_jar: &CookieJar) -> Result { + match authenticate_user_with_jwt( + db, + cookie_jar + .get("jwt") + .map(|cookie| cookie.value_trimmed()) + .ok_or(Error::Forbidden)?, + ) + .await? + { + ParsedJwt::Valid(user) => check_if_user_is_admin(db, &user) + .await? + .ok_or(Error::Forbidden), + ParsedJwt::InvalidSignature => Err(Error::Forbidden), + ParsedJwt::UserNotFound => Err(Error::Forbidden), + ParsedJwt::Expired(user) => Err(Error::JwtExpired(user)), + } +} + +#[tracing::instrument] +async fn root(cookie_jar: CookieJar, State(db): State) -> Result { Ok(if !db.has_admin_users().await? { - CreateFirstUserTemplate {}.into_response() + Redirect::temporary("admin/create_first_admin_user").into_response() } else { - IndexTemplate {}.into_response() + let admin_user = check_jwt(&db, &cookie_jar).await?; + IndexTemplate { + admin_user_name: &admin_user.real_name, + } + .into_response() }) } -#[derive(Deserialize)] +#[tracing::instrument] +async fn get_create_first_admin_user() -> CreateFirstUserTemplate { + CreateFirstUserTemplate {} +} + +#[derive(Deserialize, Debug)] struct CreateFirstUserParameters { real_name: String, email: String, password: String, } -async fn create_first_admin_user( +#[tracing::instrument] +async fn post_create_first_admin_user( + cookie_jar: CookieJar, State(db): State, Form(params): Form, -) -> Result { +) -> Result<(CookieJar, FirstLoginTemplate), Error> { let user = db .create_first_admin_user( ¶ms.real_name, @@ -58,12 +103,15 @@ async fn create_first_admin_user( &Password::new(¶ms.password)?.into(), ) .await?; - if let Some(_user) = authenticate_user(&db, user, ¶ms.password).await? { - // Store cookie and display configuration page - todo!(); - } else { - // Report failure - todo!(); - } - Ok(FirstLoginTemplate {}) + let user = authenticate_user_with_password(&db, user, ¶ms.password) + .await? + .ok_or(Error::Unexpected( + "Could not authenticate newly-created user.".to_string(), + ))?; + Ok(( + cookie_jar.add( + Cookie::build(("jwt", create_jwt_for_user(&user)?)).same_site(SameSite::Strict), + ), + FirstLoginTemplate {}, + )) } diff --git a/src/authentication/jwt.rs b/src/authentication/jwt.rs new file mode 100644 index 0000000..82ced9b --- /dev/null +++ b/src/authentication/jwt.rs @@ -0,0 +1,176 @@ +use { + base64::engine::{general_purpose::STANDARD as base64_encoder, Engine as _}, + chrono::{DateTime, Duration, Utc}, + hmac::{Hmac, Mac}, + serde::{Deserialize, Serialize}, + sha2::Sha256, + tracing::{error, warn}, +}; + +use { + super::AuthenticatedUser, + crate::config::get_config, + crate::db::{Database, User, UserId}, +}; + +const COOKIE_EXPIRY_TIME: Duration = Duration::weeks(1); + +#[derive(Debug)] +pub enum Error { + BadJwt, + + #[allow(clippy::enum_variant_names)] + DatabaseError, + + #[allow(clippy::enum_variant_names)] + HmacError, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Error::BadJwt => "BadJwt", + Error::DatabaseError => "DatabaseError", + Error::HmacError => "HmacError", + } + ) + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(value: serde_json::Error) -> Self { + warn!(details = value.to_string(), "JWT contained invalid JSON"); + Error::BadJwt + } +} + +impl From for Error { + fn from(value: base64::DecodeError) -> Self { + warn!(details = value.to_string(), "JWT contained invalid BASE64"); + Error::BadJwt + } +} + +impl From for Error { + fn from(value: std::string::FromUtf8Error) -> Self { + warn!(details = value.to_string(), "JWT contained invalid UTF-8"); + Error::BadJwt + } +} + +impl From for Error { + fn from(value: crate::db::Error) -> Self { + error!(details = value.to_string(), "Database error"); + Error::DatabaseError + } +} + +impl From for Error { + fn from(_value: digest::MacError) -> Self { + error!("Bug in JWT HMAC code."); + Error::HmacError + } +} + +type Result = std::result::Result; + +/// Result type for [authenticate_user_with_jwt()]. +#[derive(Debug)] +pub enum ParsedJwt { + /// JWT is a valid JWT, here is the [AuthenticatedUser]. + Valid(AuthenticatedUser), + /// JWT was valid but is expired + Expired(User), + /// JWT signature does not match contents + InvalidSignature, + /// JWT is valid, but the user id is not in the database + UserNotFound, +} + +#[derive(Serialize, Deserialize)] +struct Header<'a> { + #[serde(rename = "alg")] + algorithm: &'a str, + #[serde(rename = "typ")] + token_type: &'a str, +} + +#[derive(Serialize, Deserialize)] +struct Payload { + #[serde(rename = "sub")] + user_id: UserId, + + #[serde(rename = "exp")] + expiry: DateTime, +} + +fn mac() -> Hmac { + Hmac::new_from_slice(&get_config().unwrap().hmac_secret) + .expect("HMAC can take key of any size.") +} + +/// Given an [AuthenticatedUser], create a JWT for use as a cookie to +/// keep that user logged in. +#[tracing::instrument] +pub fn create_jwt_for_user(user: &AuthenticatedUser) -> Result { + let header = base64_encoder.encode( + serde_json::to_string(&Header { + algorithm: "HS256", + token_type: "JWT", + })? + .as_bytes(), + ); + let payload = base64_encoder.encode( + serde_json::to_string(&Payload { + user_id: user.get_id(), + expiry: Utc::now() + COOKIE_EXPIRY_TIME, + })? + .as_bytes(), + ); + let mut mac = mac(); + mac.update(format!("{}.{}", header, payload).as_bytes()); + let signature = base64_encoder.encode(mac.finalize().into_bytes()); + Ok(format!("{}.{}.{}", header, payload, signature)) +} + +/// Given JWT string created by [create_jwt_for_user()], check if the +/// JWT is valid and return an [AuthenticatedUser] if it is. +#[tracing::instrument] +pub async fn authenticate_user_with_jwt(db: &Database, jwt: &str) -> Result { + if let [header, payload, signature] = jwt.split('.').collect::>().as_slice() { + let mut mac = mac(); + mac.update(format!("{}.{}", header, payload).as_bytes()); + if mac.verify_slice(signature.as_bytes()).is_err() { + Ok(ParsedJwt::InvalidSignature) + } else { + let header_json = String::from_utf8(base64_encoder.decode(header)?)?; + let header: Header = serde_json::from_str(&header_json)?; + if header.algorithm != "HS256" || header.token_type != "JWT" { + warn!("JWT does not have expected algorithm or type."); + Err(Error::BadJwt) + } else { + let payload: Payload = + serde_json::from_str(&String::from_utf8(base64_encoder.decode(payload)?)?)?; + Ok(dbg!( + if let Some(user) = db.get_user_with_id(payload.user_id).await? { + if payload.expiry < Utc::now() { + ParsedJwt::Valid(AuthenticatedUser(user)) + } else { + ParsedJwt::Expired(user) + } + } else { + ParsedJwt::UserNotFound + }, + )) + } + } + } else { + warn!("Invalid JWT"); + Err(Error::BadJwt) + } +} diff --git a/src/authentication.rs b/src/authentication/mod.rs similarity index 76% rename from src/authentication.rs rename to src/authentication/mod.rs index 326c628..b124534 100644 --- a/src/authentication.rs +++ b/src/authentication/mod.rs @@ -9,6 +9,10 @@ use { std::ops::Deref, }; +mod jwt; + +pub use jwt::{authenticate_user_with_jwt, create_jwt_for_user, Error as JwtError, ParsedJwt}; + #[derive(thiserror::Error, Debug)] pub enum AuthenticationError { #[error("Could not get password hash from database: {}", .0.to_string())] @@ -18,29 +22,25 @@ pub enum AuthenticationError { HashError(#[from] argon2::password_hash::Error), } -#[derive(Debug)] -pub struct AuthenticatedUser { - user: db::User, -} +#[derive(Debug, Clone)] +pub struct AuthenticatedUser(db::User); impl Deref for AuthenticatedUser { type Target = db::User; fn deref(&self) -> &db::User { - &self.user + &self.0 } } #[derive(Debug)] -pub struct AuthenticatedAdminUser { - user: db::User, -} +pub struct AuthenticatedAdminUser(db::User); impl Deref for AuthenticatedAdminUser { type Target = db::User; fn deref(&self) -> &db::User { - &self.user + &self.0 } } @@ -81,15 +81,26 @@ impl From for db::PasswordHash { } } -pub async fn authenticate_user( +pub async fn authenticate_user_with_password( db: &Database, user: db::User, supplied_password: &str, ) -> Result, AuthenticationError> { let password: Password = db.get_password_for_user(&user).await?.into(); Ok(if password.check(supplied_password)? { - Some(AuthenticatedUser { user }) + Some(AuthenticatedUser(user)) } else { None }) } + +pub async fn check_if_user_is_admin( + db: &Database, + user: &AuthenticatedUser, +) -> Result, db::Error> { + if db.is_user_admin(user).await? { + Ok(Some(AuthenticatedAdminUser(user.0.clone()))) + } else { + Ok(None) + } +} diff --git a/src/config.rs b/src/config.rs index 63f91a0..322bd0b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,6 +11,7 @@ pub enum Error { pub struct Config { pub database_url: String, pub static_file_path: String, + pub hmac_secret: Vec, } fn get_config_string(variable: &str) -> Result { @@ -22,5 +23,6 @@ pub fn get_config() -> Result { Ok(Config { database_url: get_config_string("DATABASE_URL")?, static_file_path: get_config_string("STATIC_FILE_PATH")?, + hmac_secret: get_config_string("HMAC_SECRET")?.into_bytes(), }) } diff --git a/src/db/mod.rs b/src/db/mod.rs index f32c997..0981534 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -7,30 +7,83 @@ mod migrations; use { deadpool_postgres::{CreatePoolError, Pool, Runtime}, + serde::{Deserialize, Serialize}, + std::ops::Deref, tokio_postgres::NoTls, - std::ops::Deref + tracing::error, }; /// Errors that may occur during module initialization -#[derive(thiserror::Error, Debug)] +#[derive(Debug)] pub enum InitialisationError { - #[error("Could not initialize DB connection pool: {}", .0.to_string())] - ConnectionPoolError(#[from] CreatePoolError), + ConnectionPoolError(CreatePoolError), } +impl std::fmt::Display for InitialisationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + InitialisationError::ConnectionPoolError(e) => { + write!(f, "Could not initialise DB connection pool: {}", e) + } + } + } +} + +impl std::error::Error for InitialisationError {} + +impl From for InitialisationError { + fn from(value: CreatePoolError) -> Self { + InitialisationError::ConnectionPoolError(value) + } +} + +pub type InitialisationResult = std::result::Result; + /// Errors that may occur during normal app operation -#[derive(thiserror::Error, Debug)] +#[derive(Debug)] pub enum Error { - #[error("{}", .0.to_string())] - Pool(#[from] deadpool_postgres::PoolError), - - #[error("{}", .0.to_string())] - Postgres(#[from] tokio_postgres::Error), - - #[error("Not allowed.")] - NotAllowed + Pool(deadpool_postgres::PoolError), + Postgres(tokio_postgres::Error), + NotAllowed, } +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Pool(e) => e.fmt(f), + Error::Postgres(e) => e.fmt(f), + Error::NotAllowed => write!(f, "Not Allowed"), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(value: deadpool_postgres::PoolError) -> Self { + error!( + details = value.to_string(), + "Error with deadpool_postgress connection pool" + ); + Self::Pool(value) + } +} + +impl From for Error { + fn from(value: tokio_postgres::Error) -> Self { + error!( + details = value + .as_db_error() + .and_then(|db_error| db_error.detail()) + .unwrap_or(&value.to_string()), + "PostgreSQL error" + ); + Error::Postgres(value) + } +} + +pub type Result = std::result::Result; + /// Object that manages the database. /// /// All database access happens through this struct. @@ -39,15 +92,22 @@ pub struct Database { connection_pool: Pool, } -#[derive(Debug)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct UserId(i32); -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct User { id: UserId, pub real_name: String, } +impl User { + pub fn get_id(&self) -> UserId { + self.id + } +} + +#[derive(Debug)] pub struct PasswordHash(pub String); impl Deref for PasswordHash { @@ -60,7 +120,7 @@ impl Deref for PasswordHash { impl Database { /// Create a connection pool and return the [Database]. - pub fn new(connection_url: &str) -> Result { + pub fn new(connection_url: &str) -> InitialisationResult { let mut config = deadpool_postgres::Config::new(); config.url = Some(connection_url.to_string()); let pg_pool = config.create_pool(Some(Runtime::Tokio1), NoTls)?; @@ -71,15 +131,16 @@ impl Database { /// Run migrations as needed to ensure the database schema version /// match the one used by the current version of the application. - pub async fn migrate_to_current_version(&self) -> Result<(), Error> { + pub async fn migrate_to_current_version(&self) -> Result<()> { migrations::migrate_to_current_version(self).await } - async fn get_client(&self) -> Result { + async fn get_client(&self) -> Result { Ok(self.connection_pool.get().await?) } - pub async fn has_admin_users(&self) -> Result { + #[tracing::instrument] + pub async fn has_admin_users(&self) -> Result { let client = self.get_client().await?; Ok(client .query_one("SELECT EXISTS(SELECT 1 FROM admin_users);", &[]) @@ -87,19 +148,20 @@ impl Database { .get(0)) } + #[tracing::instrument] pub async fn create_user( &self, real_name: &str, email: &str, password: &PasswordHash, - ) -> Result { + ) -> Result { let client = self.get_client().await?; let id = client .query_one( r#" INSERT INTO users (real_name, email, password) - VALUES $1, $1, $3, $4 + VALUES ($1, $2, $3) RETURNING id;"#, &[&real_name, &email, &password.0], ) @@ -111,34 +173,56 @@ impl Database { }) } - pub async fn get_password_for_user(&self, user: &User) -> Result { + #[tracing::instrument] + pub async fn get_password_for_user(&self, user: &User) -> Result { let client = self.get_client().await?; let row = client - .query_one( - "SELECT password FROM users WHERE id = $1;", - &[&user.id.0], - ) + .query_one("SELECT password FROM users WHERE id = $1;", &[&user.id.0]) .await?; Ok(PasswordHash(row.get(0))) } + #[tracing::instrument] pub async fn create_first_admin_user( &self, real_name: &str, email: &str, password: &PasswordHash, - ) -> Result { + ) -> Result { if self.has_admin_users().await? { - return Err(Error::NotAllowed) + return Err(Error::NotAllowed); } let user = self.create_user(real_name, email, password).await?; let client = self.get_client().await?; client - .execute("INSERT INTO admin_users (id) VALUES $1", &[&user.id.0]) + .execute("INSERT INTO admin_users (id) VALUES ($1)", &[&user.id.0]) .await?; Ok(User { id: user.id, real_name: user.real_name, }) } + + #[tracing::instrument] + pub async fn is_user_admin(&self, user: &User) -> Result { + Ok(self + .get_client() + .await? + .query_opt("SELECT 1 FROM admin_users WHERE id = $1;", &[&user.id.0]) + .await? + .is_some()) + } + + #[tracing::instrument] + pub async fn get_user_with_id(&self, user_id: UserId) -> Result> { + Ok(self + .get_client() + .await? + .query_opt("SELECT real_name FROM users WHERE id = $1;", &[&user_id.0]) + .await? + .map(|row| User { + id: user_id, + real_name: row.get(0), + })) + } } diff --git a/src/error.rs b/src/error.rs index 9fd2222..4d840b4 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,34 +1,66 @@ use { crate::{authentication, db}, + askama::Template, askama_axum::{IntoResponse, Response}, + http::status::StatusCode, + tracing::{error, warn}, }; +#[allow(clippy::enum_variant_names)] #[derive(thiserror::Error, Debug)] pub enum Error { #[error("Database Error: {}", 0.to_string())] DatabaseError(#[from] db::Error), #[error("Authentication error")] - AuthenticationError(#[from] authentication::AuthenticationError) + AuthenticationError(#[from] authentication::AuthenticationError), + + #[error("Unexpected error: {}", .0)] + Unexpected(String), + + #[error("Forbidden")] + Forbidden, + + #[error("JWT Expired")] + JwtExpired(db::User), + + #[error("JWT Error")] + JwtError(#[from] authentication::JwtError), +} + +#[derive(Template)] +#[template(path = "error.html")] +struct ErrorTemplate<'a> { + title: &'a str, } impl IntoResponse for Error { fn into_response(self) -> Response { match self { - Error::DatabaseError(db::Error::Pool(pool_error)) => { - eprintln!("Database connection pool error: {}", pool_error); - todo!() - } - Error::DatabaseError(db::Error::Postgres(postgres_error)) => { - eprintln!("Database error: {}", postgres_error); - todo!() - } - Error::DatabaseError(db::Error::NotAllowed) => { - todo!() + Error::DatabaseError(_) => { + error!("Uncaught database error producing HTTP 500."); + ( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorTemplate { title: "Error" }, + ) + .into_response() } Error::AuthenticationError(_) => { todo!() } + Error::Unexpected(_) => { + todo!() + } + Error::Forbidden => { + (StatusCode::UNAUTHORIZED, "User not authorized.").into_response() + } + Error::JwtExpired(_) => { + todo!() + } + Error::JwtError(jwt_error) => { + warn!(detail = jwt_error.to_string(), "Checking JWT"); + (StatusCode::UNAUTHORIZED, ErrorTemplate { title: "Error" }).into_response() + } } } } diff --git a/src/main.rs b/src/main.rs index c6b9c0d..74b1675 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,12 @@ -use {thiserror::Error, tower_http::services::ServeDir}; +use { + thiserror::Error, + tower_http::{services::ServeDir, trace::TraceLayer}, + tracing::Level, +}; mod admin; -mod authentication; mod app; +mod authentication; mod config; mod db; mod error; @@ -20,6 +24,9 @@ pub enum Error { #[error("{0}")] IOError(#[from] std::io::Error), + + #[error("{0}")] + TracingError(#[from] tracing::subscriber::SetGlobalDefaultError), } fn main() { @@ -36,6 +43,12 @@ fn main() { async fn locality_main() -> Result<(), Error> { let config = get_config()?; + let subscriber = tracing_subscriber::FmtSubscriber::builder() + .pretty() + .with_max_level(Level::DEBUG) + .finish(); + tracing::subscriber::set_global_default(subscriber)?; + let db_pool = Database::new(&config.database_url)?; db_pool.migrate_to_current_version().await.unwrap(); @@ -43,7 +56,8 @@ async fn locality_main() -> Result<(), Error> { let app = app::routes() .nest("/admin", admin::routes()) .with_state(db_pool) - .nest_service("/static", ServeDir::new(&config.static_file_path)); + .nest_service("/static", ServeDir::new(&config.static_file_path)) + .layer(TraceLayer::new_for_http()); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; axum::serve(listener, app).await?; diff --git a/templates/admin/create_first_user.html b/templates/admin/create_first_user.html index 9befb98..ec11161 100644 --- a/templates/admin/create_first_user.html +++ b/templates/admin/create_first_user.html @@ -4,7 +4,7 @@ {% block content %}

Please create an administrator account:

-
> +>
  • diff --git a/templates/admin/index.html b/templates/admin/index.html index d9e91df..3461764 100644 --- a/templates/admin/index.html +++ b/templates/admin/index.html @@ -2,4 +2,4 @@ {% block title %}Locality Administration{% endblock %} -{% block content %}admin{% endblock %} +{% block content %}{{admin_user_name}}{% endblock %} diff --git a/templates/base.html b/templates/base.html index 95dc436..6d169d8 100644 --- a/templates/base.html +++ b/templates/base.html @@ -5,15 +5,15 @@ {% block title %}{{title}} - Locality{% endblock %} - - + + - - + + - + - + {% block head %}{% endblock %} diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..d98917b --- /dev/null +++ b/templates/error.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} + +{% block content %}Something went wrong. Please try again later.{% endblock %}