Compare commits
10 Commits
464a05638b
...
f46c68f036
| Author | SHA1 | Date |
|---|---|---|
|
|
f46c68f036 | |
|
|
9b1e9733a4 | |
|
|
2f27c6ee68 | |
|
|
2ff05bc500 | |
|
|
f73b5c16b9 | |
|
|
e7d0780b1b | |
|
|
9870b11664 | |
|
|
ef9951fcbe | |
|
|
042f6b17aa | |
|
|
06a6811972 |
|
|
@ -15,7 +15,7 @@
|
||||||
// "features": {},
|
// "features": {},
|
||||||
|
|
||||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
// "forwardPorts": [5432],
|
"forwardPorts": [5432],
|
||||||
|
|
||||||
// Use 'postCreateCommand' to run commands after the container is created.
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
// "postCreateCommand": "rustc --version",
|
// "postCreateCommand": "rustc --version",
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,4 @@
|
||||||
backend/target
|
backend/target
|
||||||
backend/db
|
backend/db
|
||||||
|
backend/id_rsa
|
||||||
|
backend/id_rsa.pub
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
-- Add up migration script here
|
|
||||||
CREATE TABLE "oauth2_state_storage" (
|
|
||||||
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"csrf_state" text NOT NULL,
|
|
||||||
"pkce_code_verifier" text NOT NULL,
|
|
||||||
"return_url" text NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "user_sessions" (
|
|
||||||
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"user_id" integer NOT NULL,
|
|
||||||
"session_token_p1" text NOT NULL,
|
|
||||||
"session_token_p2" text NOT NULL,
|
|
||||||
"created_at" integer NOT NULL,
|
|
||||||
"expires_at" integer NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "users" (
|
|
||||||
"id" integer NOT NULL,
|
|
||||||
"email" text NOT NULL UNIQUE,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"family_name" text NOT NULL,
|
|
||||||
"given_name" text NOT NULL,
|
|
||||||
PRIMARY KEY("id" AUTOINCREMENT)
|
|
||||||
);
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
-- Add up migration script here
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS roles (
|
|
||||||
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"description" TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_roles (
|
|
||||||
"user_id" integer NOT NULL,
|
|
||||||
"role_id" integer NOT NULL,
|
|
||||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
create TABLE IF NOT EXISTS role_permissions (
|
|
||||||
"role_id" integer NOT NULL,
|
|
||||||
"item" text NOT NULL,
|
|
||||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
-- Add up migration script here
|
|
||||||
INSERT INTO "main"."roles" ("id", "created_at", "updated_at", "name", "description") VALUES ('1', '2024-09-27 00:58:54', '2024-09-27 00:58:54', 'public', 'Users with only anonymous access');
|
|
||||||
INSERT INTO "main"."roles" ("id", "created_at", "updated_at", "name", "description") VALUES ('2', '2024-09-27 00:59:24', '2024-09-27 00:59:24', 'normal', 'Users with no elevated privileges');
|
|
||||||
INSERT INTO "main"."roles" ("id", "created_at", "updated_at", "name", "description") VALUES ('3', '2024-09-27 01:00:16', '2024-09-27 01:00:16', 'editor', 'Users with basic elevated privileges');
|
|
||||||
INSERT INTO "main"."roles" ("id", "created_at", "updated_at", "name", "description") VALUES ('4', '2024-09-27 01:00:53', '2024-09-27 01:00:53', 'admin', 'Users with full administrative privileges');
|
|
||||||
|
|
||||||
INSERT INTO "main"."role_permissions" ("role_id", "item", "created_at", "updated_at") VALUES ('4', '/useradmin', '2024-09-27 01:01:59', '2024-09-27 01:01:59');
|
|
||||||
INSERT INTO "main"."role_permissions" ("role_id", "item", "created_at", "updated_at") VALUES ('4', '/users', '2024-09-28 12:14:49', '2024-09-28 12:14:49');
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
-- Add up migration script here
|
||||||
|
CREATE TABLE "oauth2_state_storage" (
|
||||||
|
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"csrf_state" text NOT NULL,
|
||||||
|
"pkce_code_verifier" text NOT NULL,
|
||||||
|
"return_url" text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "user_sessions" (
|
||||||
|
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"session_token_p1" text NOT NULL,
|
||||||
|
"session_token_p2" text NOT NULL,
|
||||||
|
"created_at" integer NOT NULL,
|
||||||
|
"expires_at" integer NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"created_at" integer NOT NULL,
|
||||||
|
"created_by" integer NOT NULL,
|
||||||
|
"updated_at" integer NOT NULL,
|
||||||
|
"updated_by" integer NOT NULL,
|
||||||
|
"email" text NOT NULL UNIQUE,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"family_name" text NOT NULL,
|
||||||
|
"given_name" text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
|
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"created_at" integer NOT NULL,
|
||||||
|
"created_by" integer NOT NULL,
|
||||||
|
"updated_at" integer NOT NULL,
|
||||||
|
"updated_by" integer NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_roles (
|
||||||
|
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"created_at" integer NOT NULL,
|
||||||
|
"created_by" integer NOT NULL,
|
||||||
|
"updated_at" integer NOT NULL,
|
||||||
|
"updated_by" integer NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"role_id" integer NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
create TABLE IF NOT EXISTS role_permissions (
|
||||||
|
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"created_at" integer NOT NULL,
|
||||||
|
"created_by" integer NOT NULL,
|
||||||
|
"updated_at" integer NOT NULL,
|
||||||
|
"updated_by" integer NOT NULL,
|
||||||
|
"role_id" integer NOT NULL,
|
||||||
|
"item" text NOT NULL
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- Add up migration script here
|
||||||
|
INSERT INTO "main"."roles" ("id", "created_at", "created_by", "updated_at", "updated_by", "name", "description") VALUES ('1', '0', '0', '0', '0', 'public', 'Users with only anonymous access');
|
||||||
|
INSERT INTO "main"."roles" ("id", "created_at", "created_by", "updated_at", "updated_by", "name", "description") VALUES ('2', '0', '0', '0', '0', 'normal', 'Users with no elevated privileges');
|
||||||
|
INSERT INTO "main"."roles" ("id", "created_at", "created_by", "updated_at", "updated_by", "name", "description") VALUES ('3', '0', '0', '0', '0', 'editor', 'Users with basic elevated privileges');
|
||||||
|
INSERT INTO "main"."roles" ("id", "created_at", "created_by", "updated_at", "updated_by", "name", "description") VALUES ('4', '0', '0', '0', '0', 'admin', 'Users with full administrative privileges');
|
||||||
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- Add up migration script here
|
||||||
|
-- Role permissions
|
||||||
|
INSERT INTO "main"."role_permissions" ("id", "created_at", "created_by", "updated_at", "updated_by", "role_id", "item") VALUES ('1', '0', '0', '0', '0', '1', '/');
|
||||||
|
INSERT INTO "main"."role_permissions" ("id", "created_at", "created_by", "updated_at", "updated_by", "role_id", "item") VALUES ('2', '0', '0', '0', '0', '1', '/login');
|
||||||
|
INSERT INTO "main"."role_permissions" ("id", "created_at", "created_by", "updated_at", "updated_by", "role_id", "item") VALUES ('3', '0', '0', '0', '0', '1', '/logout');
|
||||||
|
INSERT INTO "main"."role_permissions" ("id", "created_at", "created_by", "updated_at", "updated_by", "role_id", "item") VALUES ('4', '0', '0', '0', '0', '2', '/dashboard');
|
||||||
|
INSERT INTO "main"."role_permissions" ("id", "created_at", "created_by", "updated_at", "updated_by", "role_id", "item") VALUES ('5', '0', '0', '0', '0', '2', '/profile');
|
||||||
|
INSERT INTO "main"."role_permissions" ("id", "created_at", "created_by", "updated_at", "updated_by", "role_id", "item") VALUES ('6', '0', '0', '0', '0', '4', '/useradmin');
|
||||||
|
INSERT INTO "main"."role_permissions" ("id", "created_at", "created_by", "updated_at", "updated_by", "role_id", "item") VALUES ('7', '0', '0', '0', '0', '4', '/users');
|
||||||
|
|
||||||
|
-- First user is an admin
|
||||||
|
INSERT INTO "main"."user_roles" ("id", "created_at", "created_by", "updated_at", "updated_by", "user_id", "role_id") VALUES ('2', '0', '0', '0', '0', '1', '4');
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- Add down migration script here
|
||||||
|
-- Delete role records
|
||||||
|
DELETE FROM "main"."roles" WHERE "id" = '5';
|
||||||
|
|
||||||
|
-- Delete permission records
|
||||||
|
DELETE FROM "main"."role_permissions" WHERE "id" = '8';
|
||||||
|
|
||||||
|
-- Delete user role records
|
||||||
|
DELETE FROM "main"."user_roles" WHERE "role_id" = '5';
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- Add up migration script here
|
||||||
|
-- Add roles for calendar
|
||||||
|
INSERT INTO "main"."roles" ("id", "created_at", "created_by", "updated_at", "updated_by", "name", "description") VALUES ('5', '0', '0', '0', '0', 'calendar', 'Users with access to the calendar');
|
||||||
|
|
||||||
|
-- Add permissions for calendar
|
||||||
|
INSERT INTO "main"."role_permissions" ("id", "created_at", "created_by", "updated_at", "updated_by", "role_id", "item") VALUES ('8', '0', '0', '0', '0', '5', '/cottagecalendar');
|
||||||
|
|
||||||
|
-- Add user roles for calendar
|
||||||
|
INSERT INTO "main"."user_roles" ("id", "created_at", "created_by", "updated_at", "updated_by", "user_id", "role_id") VALUES ('1', '0', '0', '0', '0', '1', '5');
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
#ssh chris@192.168.59.31 'pkill jean-marie'
|
cargo build --release
|
||||||
|
ssh chris@192.168.59.31 'pkill jean-marie'
|
||||||
scp target/release/jean-marie chris@192.168.59.31:/opt/jean-marie
|
scp target/release/jean-marie chris@192.168.59.31:/opt/jean-marie
|
||||||
|
scp .env www@192.168.59.31:/opt/jean-marie
|
||||||
scp -r templates chris@192.168.59.31:/opt/jean-marie
|
scp -r templates chris@192.168.59.31:/opt/jean-marie
|
||||||
#ssh chris@192.168.59.31 'cd /opt/jean-marie && ./jean-marie&'
|
ssh chris@192.168.59.31 'cd /opt/jean-marie && ./jean-marie&'
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,268 @@
|
||||||
use axum::
|
// Code adapted from https://github.com/ramosbugs/oauth2-rs/blob/main/examples/google.rs
|
||||||
response::{IntoResponse, Redirect}
|
//
|
||||||
;
|
// Must set the enviroment variables:
|
||||||
use oauth2::{
|
// GOOGLE_CLIENT_ID=xxx
|
||||||
basic::BasicClient, AuthUrl, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, TokenUrl
|
// GOOGLE_CLIENT_SECRET=yyy
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use std::env;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
use axum::{
|
||||||
#[allow(dead_code)]
|
extract::{Extension, Host, Query, State},
|
||||||
pub struct AuthRequest {
|
response::{IntoResponse, Redirect},
|
||||||
code: String,
|
};
|
||||||
state: String,
|
use axum_extra::TypedHeader;
|
||||||
|
use dotenvy::var;
|
||||||
|
use headers::Cookie;
|
||||||
|
use oauth2::{
|
||||||
|
basic::BasicClient, reqwest::http_client, AuthUrl, AuthorizationCode, ClientId, ClientSecret,
|
||||||
|
CsrfToken, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RevocationUrl, Scope,
|
||||||
|
TokenResponse, TokenUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::{AppError, UserData};
|
||||||
|
|
||||||
|
fn get_client(hostname: String) -> Result<BasicClient, AppError> {
|
||||||
|
let google_client_id = ClientId::new(var("GOOGLE_CLIENT_ID")?);
|
||||||
|
let google_client_secret = ClientSecret::new(var("GOOGLE_CLIENT_SECRET")?);
|
||||||
|
let auth_url = AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string())
|
||||||
|
.map_err(|_| "OAuth: invalid authorization endpoint URL")?;
|
||||||
|
let token_url = TokenUrl::new("https://www.googleapis.com/oauth2/v3/token".to_string())
|
||||||
|
.map_err(|_| "OAuth: invalid token endpoint URL")?;
|
||||||
|
|
||||||
|
let protocol = if hostname.starts_with("localhost") || hostname.starts_with("127.0.0.1") {
|
||||||
|
"http"
|
||||||
|
} else {
|
||||||
|
"https"
|
||||||
|
};
|
||||||
|
|
||||||
|
let redirect_url = format!("{}://{}/google_auth_return", protocol, hostname);
|
||||||
|
|
||||||
|
// Set up the config for the Google OAuth2 process.
|
||||||
|
let client = BasicClient::new(
|
||||||
|
google_client_id,
|
||||||
|
Some(google_client_secret),
|
||||||
|
auth_url,
|
||||||
|
Some(token_url),
|
||||||
|
)
|
||||||
|
.set_redirect_uri(RedirectUrl::new(redirect_url).map_err(|_| "OAuth: invalid redirect URL")?)
|
||||||
|
.set_revocation_uri(
|
||||||
|
RevocationUrl::new("https://oauth2.googleapis.com/revoke".to_string())
|
||||||
|
.map_err(|_| "OAuth: invalid revocation endpoint URL")?,
|
||||||
|
);
|
||||||
|
Ok(client)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn google_auth() -> impl IntoResponse {
|
pub async fn login(
|
||||||
let (pkce_code_challenge, _pkce_code_verifier) = PkceCodeChallenge::new_random_sha256();
|
Extension(user_data): Extension<Option<UserData>>,
|
||||||
|
Query(mut params): Query<HashMap<String, String>>,
|
||||||
|
State(db_pool): State<SqlitePool>,
|
||||||
|
Host(hostname): Host,
|
||||||
|
) -> Result<Redirect, AppError> {
|
||||||
|
if user_data.is_some() {
|
||||||
|
// check if already authenticated
|
||||||
|
return Ok(Redirect::to("/"));
|
||||||
|
}
|
||||||
|
|
||||||
// Generate the authorization URL to which we'll redirect the user.
|
let return_url = params
|
||||||
let (auth_url, _csrf_state) = google_oauth_client()
|
.remove("return_url")
|
||||||
|
.unwrap_or_else(|| "/".to_string());
|
||||||
|
// TODO: check if return_url is valid
|
||||||
|
|
||||||
|
let client = get_client(hostname)?;
|
||||||
|
|
||||||
|
let (pkce_code_challenge, pkce_code_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||||
|
|
||||||
|
let (authorize_url, csrf_state) = client
|
||||||
.authorize_url(CsrfToken::new_random)
|
.authorize_url(CsrfToken::new_random)
|
||||||
.add_scope(Scope::new(
|
.add_scope(Scope::new(
|
||||||
"https://www.googleapis.com/auth/userinfo.profile".to_string(),
|
"https://www.googleapis.com/auth/userinfo.email".to_string(),
|
||||||
))
|
))
|
||||||
.add_scope(Scope::new(
|
.add_scope(Scope::new(
|
||||||
"https://www.googleapis.com/auth/userinfo.email".to_string(),
|
"https://www.googleapis.com/auth/userinfo.profile".to_string(),
|
||||||
))
|
))
|
||||||
.set_pkce_challenge(pkce_code_challenge)
|
.set_pkce_challenge(pkce_code_challenge)
|
||||||
.url();
|
.url();
|
||||||
|
|
||||||
// Redirect to Google's oauth service
|
sqlx::query(
|
||||||
Redirect::to(&auth_url.to_string())
|
"INSERT INTO oauth2_state_storage (csrf_state, pkce_code_verifier, return_url) VALUES (?, ?, ?);",
|
||||||
}
|
|
||||||
|
|
||||||
pub fn google_oauth_client() -> BasicClient {
|
|
||||||
if std::env::var_os("GOOGLE_CLIENT_ID").is_none() {
|
|
||||||
std::env::set_var("GOOGLE_CLIENT_ID", "735264084619-clsmvgdqdmum4rvrcj0kuk28k9agir1c.apps.googleusercontent.com")
|
|
||||||
}
|
|
||||||
if std::env::var_os("GOOGLE_CLIENT_SECRET").is_none() {
|
|
||||||
std::env::set_var("GOOGLE_CLIENT_SECRET", "L6uI7FQGoMJd-ay1HO_iGJ6M")
|
|
||||||
}
|
|
||||||
|
|
||||||
let redirect_url = env::var("REDIRECT_URL")
|
|
||||||
.unwrap_or_else(|_| "http://localhost:40192/google_auth_return".to_string());
|
|
||||||
// .unwrap_or_else(|_| "https://www.jean-marie.ca/auth/google".to_string());
|
|
||||||
|
|
||||||
let google_client_id = env::var("GOOGLE_CLIENT_ID").expect("Missing GOOGLE_CLIENT_ID!");
|
|
||||||
let google_client_secret =
|
|
||||||
env::var("GOOGLE_CLIENT_SECRET").expect("Missing GOOGLE_CLIENT_SECRET!");
|
|
||||||
let google_auth_url = env::var("GOOGLE_AUTH_URL")
|
|
||||||
.unwrap_or_else(|_| "https://accounts.google.com/o/oauth2/v2/auth".to_string());
|
|
||||||
let google_token_url = env::var("GOOGLE_TOKEN_URL")
|
|
||||||
.unwrap_or_else(|_| "https://www.googleapis.com/oauth2/v3/token".to_string());
|
|
||||||
|
|
||||||
BasicClient::new(
|
|
||||||
ClientId::new(google_client_id),
|
|
||||||
Some(ClientSecret::new(google_client_secret)),
|
|
||||||
AuthUrl::new(google_auth_url).unwrap(),
|
|
||||||
Some(TokenUrl::new(google_token_url).unwrap()),
|
|
||||||
)
|
)
|
||||||
.set_redirect_uri(RedirectUrl::new(redirect_url).unwrap())
|
.bind(csrf_state.secret())
|
||||||
|
.bind(pkce_code_verifier.secret())
|
||||||
|
.bind(return_url)
|
||||||
|
.execute(&db_pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Redirect::to(authorize_url.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn google_auth_return(
|
||||||
|
Query(mut params): Query<HashMap<String, String>>,
|
||||||
|
State(db_pool): State<SqlitePool>,
|
||||||
|
cookie: Option<TypedHeader<Cookie>>,
|
||||||
|
Host(hostname): Host,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let state = CsrfToken::new(params.remove("state").ok_or("OAuth: without state")?);
|
||||||
|
let code = AuthorizationCode::new(params.remove("code").ok_or("OAuth: without code")?);
|
||||||
|
let mut headers = axum::response::AppendHeaders([(
|
||||||
|
axum::http::header::SET_COOKIE,
|
||||||
|
"session_token=".to_owned() + "; path=/; httponly; secure; samesite=strict",
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let query: (String, String) = sqlx::query_as(
|
||||||
|
r#"DELETE FROM oauth2_state_storage WHERE csrf_state = ? RETURNING pkce_code_verifier,return_url"#,
|
||||||
|
)
|
||||||
|
.bind(state.secret())
|
||||||
|
.fetch_one(&db_pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let pkce_code_verifier = query.0;
|
||||||
|
let _return_url = query.1;
|
||||||
|
let pkce_code_verifier = PkceCodeVerifier::new(pkce_code_verifier);
|
||||||
|
|
||||||
|
// Exchange the code with a token.
|
||||||
|
let client = get_client(hostname)?;
|
||||||
|
let token_response = tokio::task::spawn_blocking(move || {
|
||||||
|
client
|
||||||
|
.exchange_code(code)
|
||||||
|
.set_pkce_verifier(pkce_code_verifier)
|
||||||
|
.request(http_client)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| "OAuth: exchange_code failure")?
|
||||||
|
.map_err(|_| "OAuth: tokio spawn blocking failure")?;
|
||||||
|
let access_token = token_response.access_token().secret();
|
||||||
|
|
||||||
|
// Get user info from Google
|
||||||
|
let url =
|
||||||
|
"https://www.googleapis.com/oauth2/v2/userinfo?oauth_token=".to_owned() + access_token;
|
||||||
|
let body = reqwest::get(url)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "OAuth: reqwest failed to query userinfo")?
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|_| "OAuth: reqwest received invalid userinfo")?;
|
||||||
|
let mut body: serde_json::Value =
|
||||||
|
serde_json::from_str(body.as_str()).map_err(|_| "OAuth: Serde failed to parse userinfo")?;
|
||||||
|
let email = body["email"]
|
||||||
|
.take()
|
||||||
|
.as_str()
|
||||||
|
.ok_or("OAuth: Serde failed to parse email address")?
|
||||||
|
.to_owned();
|
||||||
|
let name = body["name"]
|
||||||
|
.take()
|
||||||
|
.as_str()
|
||||||
|
.ok_or("OAuth: Serde failed to parse email address")?
|
||||||
|
.to_owned();
|
||||||
|
let family_name = body["family_name"]
|
||||||
|
.take()
|
||||||
|
.as_str()
|
||||||
|
.ok_or("OAuth: Serde failed to parse email address")?
|
||||||
|
.to_owned();
|
||||||
|
let given_name = body["given_name"]
|
||||||
|
.take()
|
||||||
|
.as_str()
|
||||||
|
.ok_or("OAuth: Serde failed to parse email address")?
|
||||||
|
.to_owned();
|
||||||
|
let verified_email = body["verified_email"]
|
||||||
|
.take()
|
||||||
|
.as_bool()
|
||||||
|
.ok_or("OAuth: Serde failed to parse verified_email")?;
|
||||||
|
if !verified_email {
|
||||||
|
return Err(AppError::new("OAuth: email address is not verified".to_owned())
|
||||||
|
.with_user_message("Your email address is not verified. Please verify your email address with Google and try again.".to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists in database
|
||||||
|
// If not, create a new user
|
||||||
|
let query: Result<(i64,), _> = sqlx::query_as(r#"SELECT id FROM users WHERE email=?"#)
|
||||||
|
.bind(email.as_str())
|
||||||
|
.fetch_one(&db_pool)
|
||||||
|
.await;
|
||||||
|
let user_id = if let Ok(query) = query {
|
||||||
|
query.0
|
||||||
|
} else {
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
|
||||||
|
// Add user
|
||||||
|
let query: (i64,) = sqlx::query_as("INSERT INTO users (created_at, created_by, updated_at, updated_by, email, name, family_name, given_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
|
||||||
|
.bind(now)
|
||||||
|
.bind(0 as i64)// Created by system
|
||||||
|
.bind(now)
|
||||||
|
.bind(0 as i64) // Updated by system
|
||||||
|
.bind(email.clone())
|
||||||
|
.bind(name.clone())
|
||||||
|
.bind(family_name.clone())
|
||||||
|
.bind(given_name.clone())
|
||||||
|
.fetch_one(&db_pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Add public role
|
||||||
|
sqlx::query("INSERT INTO user_roles (created_at, created_by, updated_at, updated_by, user_id, role_id) VALUES (?, ?, ?, ?, ?, ?)")
|
||||||
|
.bind(now)
|
||||||
|
.bind(0 as i64)// Created by system
|
||||||
|
.bind(now)
|
||||||
|
.bind(0 as i64) // Updated by system
|
||||||
|
.bind(query.0)
|
||||||
|
.bind("1")
|
||||||
|
.execute(&db_pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
query.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update session with user id or create new session
|
||||||
|
if let Some(cookie) = cookie {
|
||||||
|
if let Some(_session_token) = cookie.get("session_token") {
|
||||||
|
} else {
|
||||||
|
// Create a session for the user
|
||||||
|
let session_token_p1 = Uuid::new_v4().to_string();
|
||||||
|
let session_token_p2 = Uuid::new_v4().to_string();
|
||||||
|
let session_token =
|
||||||
|
[session_token_p1.as_str(), "_", session_token_p2.as_str()].concat();
|
||||||
|
headers = axum::response::AppendHeaders([(
|
||||||
|
axum::http::header::SET_COOKIE,
|
||||||
|
"session_token=".to_owned()
|
||||||
|
+ &*session_token
|
||||||
|
+ "; path=/; httponly; secure; samesite=strict",
|
||||||
|
)]);
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO user_sessions
|
||||||
|
(session_token_p1, session_token_p2, user_id, created_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?);",
|
||||||
|
)
|
||||||
|
.bind(session_token_p1)
|
||||||
|
.bind(session_token_p2)
|
||||||
|
.bind(user_id) // Set user to anonymous
|
||||||
|
.bind(now)
|
||||||
|
.bind(now + 60 * 60 * 24)
|
||||||
|
.execute(&db_pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((headers, Redirect::to("/")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(
|
||||||
|
cookie: Option<TypedHeader<Cookie>>,
|
||||||
|
State(db_pool): State<SqlitePool>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
if let Some(cookie) = cookie {
|
||||||
|
if let Some(session_token) = cookie.get("session_token") {
|
||||||
|
let session_token: Vec<&str> = session_token.split('_').collect();
|
||||||
|
let _ = sqlx::query("DELETE FROM user_sessions WHERE session_token_1 = ?")
|
||||||
|
.bind(session_token[0])
|
||||||
|
.execute(&db_pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let headers = axum::response::AppendHeaders([(
|
||||||
|
axum::http::header::SET_COOKIE,
|
||||||
|
"session_token=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT",
|
||||||
|
)]);
|
||||||
|
Ok((headers, Redirect::to("/")))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,26 @@
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use askama_axum::Template;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
middleware, response::{Html, IntoResponse, Response}, routing::{get, get_service}, Extension, Router
|
middleware, routing::{get, get_service}, Extension, Router
|
||||||
};
|
};
|
||||||
use http::{Request, StatusCode};
|
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::{prelude::FromRow, sqlite::SqlitePoolOptions, SqlitePool};
|
|
||||||
use sqlx::migrate::Migrator;
|
use sqlx::migrate::Migrator;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
mod error_handling;
|
mod error_handling;
|
||||||
mod google_oauth;
|
mod google_oauth;
|
||||||
mod middlewares;
|
mod middlewares;
|
||||||
mod oauth;
|
|
||||||
mod routes;
|
mod routes;
|
||||||
|
mod user;
|
||||||
|
|
||||||
use error_handling::AppError;
|
use error_handling::AppError;
|
||||||
use google_oauth::*;
|
use middlewares::inject_user_data;
|
||||||
use middlewares::{check_auth, inject_user_data};
|
use google_oauth::{login, logout, google_auth_return};
|
||||||
use oauth::{login, logout, google_auth_return};
|
use routes::{dashboard, index, about, cottagecalendar, contact, profile, user_profile, useradmin};
|
||||||
use routes::*;
|
use user::{add_user_role, delete_user_role, UserData};
|
||||||
|
|
||||||
struct HtmlTemplate<T>(T);
|
|
||||||
|
|
||||||
impl<T> IntoResponse for HtmlTemplate<T>
|
|
||||||
where
|
|
||||||
T: Template,
|
|
||||||
{
|
|
||||||
fn into_response(self) -> Response {
|
|
||||||
match self.0.render() {
|
|
||||||
Ok(html) => Html(html).into_response(),
|
|
||||||
Err(err) => (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("Failed to render template. Error: {}", err),
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "index.html")]
|
|
||||||
struct IndexTemplate {
|
|
||||||
logged_in: bool,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db_pool: SqlitePool,
|
pub db_pool: SqlitePool
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Clone, Debug, FromRow, Serialize, Deserialize)]
|
|
||||||
pub struct UserData {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub id: i64,
|
|
||||||
pub email: String,
|
|
||||||
pub name: String,
|
|
||||||
pub family_name: String,
|
|
||||||
pub given_name: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
@ -71,7 +33,7 @@ async fn main() {
|
||||||
.connect("sqlite://db/db.sqlite3")
|
.connect("sqlite://db/db.sqlite3")
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let app_state = AppState {db_pool: db_pool.expect("Failed to get db_pool") };
|
let app_state = AppState {db_pool:db_pool.expect("Failed to get db_pool")};
|
||||||
|
|
||||||
static MIGRATOR: Migrator = sqlx::migrate!();
|
static MIGRATOR: Migrator = sqlx::migrate!();
|
||||||
|
|
||||||
|
|
@ -84,19 +46,20 @@ async fn main() {
|
||||||
|
|
||||||
// build our application with some routes
|
// build our application with some routes
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
//Routes that require authentication
|
.route("/dashboard", get(dashboard))
|
||||||
|
.route("/cottagecalendar", get(cottagecalendar))
|
||||||
.route("/profile", get(profile))
|
.route("/profile", get(profile))
|
||||||
.route("/useradmin", get(useradmin))
|
.route("/useradmin", get(useradmin))
|
||||||
.route("/users/:user_id", get(user_profile))
|
.route("/users/:user_id", get(user_profile))
|
||||||
.route_layer(middleware::from_fn_with_state(app_state.db_pool.clone(), check_auth))
|
.route("/roles/:user_id/:role_id/add", get(add_user_role))
|
||||||
|
.route("/roles/:user_role_id/delete", get(delete_user_role))
|
||||||
//Routes that don't require authentication
|
|
||||||
.nest_service("/assets", ServeDir::new("templates/assets")
|
.nest_service("/assets", ServeDir::new("templates/assets")
|
||||||
.fallback(get_service(ServeDir::new("templates/assets"))))
|
.fallback(get_service(ServeDir::new("templates/assets"))))
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
|
.route("/about", get(about))
|
||||||
|
.route("/contactus", get(contact))
|
||||||
.route("/login", get(login))
|
.route("/login", get(login))
|
||||||
.route("/logout", get(logout))
|
.route("/logout", get(logout))
|
||||||
.route("/google_auth", get(google_auth))
|
|
||||||
.route("/google_auth_return", get(google_auth_return))
|
.route("/google_auth_return", get(google_auth_return))
|
||||||
.route_layer(middleware::from_fn_with_state(app_state.db_pool.clone(), inject_user_data))
|
.route_layer(middleware::from_fn_with_state(app_state.db_pool.clone(), inject_user_data))
|
||||||
.with_state(app_state.db_pool)
|
.with_state(app_state.db_pool)
|
||||||
|
|
@ -112,15 +75,3 @@ async fn main() {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn index<T>(
|
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
|
||||||
_request: Request<T>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let user_email = user_data.map(|s| s.name);
|
|
||||||
let logged_in = user_email.is_some();
|
|
||||||
let name = user_email.unwrap_or_default();
|
|
||||||
|
|
||||||
let template = IndexTemplate { logged_in, name};
|
|
||||||
HtmlTemplate(template)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
http::Request,
|
http::Request,
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{IntoResponse, Redirect},
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
use axum_extra::TypedHeader;
|
use axum_extra::TypedHeader;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
@ -55,10 +55,14 @@ pub async fn inject_user_data(
|
||||||
|
|
||||||
request.extensions_mut().insert(Some(UserData {
|
request.extensions_mut().insert(Some(UserData {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
created_at: row.created_at,
|
||||||
|
created_by: row.created_by,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
updated_by: row.updated_by,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
family_name: row.family_name,
|
family_name: row.family_name,
|
||||||
given_name: row.given_name,
|
given_name: row.given_name
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -71,54 +75,19 @@ pub async fn inject_user_data(
|
||||||
Ok(next.run(request).await)
|
Ok(next.run(request).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_auth(
|
pub async fn is_authorized(path: &str, user_data: Option<UserData>, db_pool: SqlitePool) -> bool {
|
||||||
State(app_state): State<SqlitePool>,
|
if let Some(user_data) = user_data {
|
||||||
request: Request<Body>,
|
|
||||||
next: Next,
|
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
|
||||||
if request
|
|
||||||
.extensions()
|
|
||||||
.get::<Option<UserData>>()
|
|
||||||
.ok_or("check_auth: extensions have no UserData")?
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
let path = &*request.uri().to_string();
|
|
||||||
println!("{}", path);
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
&*request
|
|
||||||
.extensions()
|
|
||||||
.get::<Option<UserData>>()
|
|
||||||
.unwrap()
|
|
||||||
.as_ref()
|
|
||||||
.unwrap()
|
|
||||||
.email
|
|
||||||
);
|
|
||||||
|
|
||||||
let query: Result<(i64,), _> = match path {
|
let query: Result<(i64,), _> = match path {
|
||||||
"/profile" => {
|
"/profile" => {
|
||||||
sqlx::query_as(r#"select u.id from users u where email =?"#)
|
return true;
|
||||||
.bind(
|
|
||||||
request
|
|
||||||
.extensions()
|
|
||||||
.get::<Option<UserData>>()
|
|
||||||
.unwrap()
|
|
||||||
.as_ref()
|
|
||||||
.unwrap()
|
|
||||||
.email
|
|
||||||
.as_str(),
|
|
||||||
)
|
|
||||||
.fetch_one(&app_state)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// loop through path to find a permission
|
// loop through path to find a permission
|
||||||
let mut remaining_path = Path::new(path);
|
let mut remaining_path = Path::new(path);
|
||||||
loop {
|
loop {
|
||||||
let query: Result<(String,), _> = sqlx::query_as(r#"select r.item from role_permissions r join user_roles ur on ur.role_id = r.role_id join users u on u.id = ur.user_id where item = ? and email =?"#)
|
let query: Result<(String,), _> = sqlx::query_as(r#"select r.item from role_permissions r where item = ?"#)
|
||||||
.bind(remaining_path.to_str().unwrap())
|
.bind(remaining_path.to_str().unwrap())
|
||||||
.bind(request.extensions().get::<Option<UserData>>().unwrap().as_ref().unwrap().email.as_str())
|
.fetch_one(&db_pool)
|
||||||
.fetch_one(&app_state)
|
|
||||||
.await;
|
.await;
|
||||||
if let Ok(query) = query {
|
if let Ok(query) = query {
|
||||||
if query.0 != "" {
|
if query.0 != "" {
|
||||||
|
|
@ -129,39 +98,20 @@ pub async fn check_auth(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
remaining_path = remaining_path.parent().unwrap();
|
remaining_path = remaining_path.parent().unwrap();
|
||||||
|
|
||||||
println!("{}", remaining_path.to_str().unwrap());
|
|
||||||
}
|
}
|
||||||
sqlx::query_as(r#"select u.id from role_permissions r join user_roles ur on ur.role_id = r.role_id join users u on u.id = ur.user_id where item = ? and email =?"#)
|
sqlx::query_as(r#"select u.id from role_permissions r join user_roles ur on ur.role_id = r.role_id join users u on u.id = ur.user_id where item = ? and email = ?"#)
|
||||||
.bind(remaining_path.to_str().unwrap())
|
.bind(remaining_path.to_str().unwrap())
|
||||||
.bind(request.extensions().get::<Option<UserData>>().unwrap().as_ref().unwrap().email.as_str())
|
.bind(user_data.email.as_str())
|
||||||
.fetch_one(&app_state)
|
.fetch_one(&db_pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// user is logged in
|
if let Ok(query) = query {
|
||||||
// check if user has the proper role
|
if query.0 == user_data.id {
|
||||||
// if not, display banner and return to home page
|
return true;
|
||||||
let user_id = if let Ok(query) = query {
|
}
|
||||||
query.0
|
|
||||||
} else {
|
|
||||||
// user does not have the proper role
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
println!("{}", user_id);
|
|
||||||
if user_id == 0 {
|
|
||||||
// user does not have the proper role
|
|
||||||
// display banner and return to home page
|
|
||||||
return Ok(Redirect::to("/").into_response());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(next.run(request).await)
|
|
||||||
} else {
|
|
||||||
// user is not logged in
|
|
||||||
// redirect to login page with return_url
|
|
||||||
let login_url = "/login?return_url=".to_owned() + &*request.uri().to_string();
|
|
||||||
Ok(Redirect::to(login_url.as_str()).into_response())
|
|
||||||
}
|
}
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
// Code adapted from https://github.com/ramosbugs/oauth2-rs/blob/main/examples/google.rs
|
|
||||||
//
|
|
||||||
// Must set the enviroment variables:
|
|
||||||
// GOOGLE_CLIENT_ID=xxx
|
|
||||||
// GOOGLE_CLIENT_SECRET=yyy
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
extract::{Extension, Host, Query, State},
|
|
||||||
response::{IntoResponse, Redirect},
|
|
||||||
};
|
|
||||||
use axum_extra::TypedHeader;
|
|
||||||
use dotenvy::var;
|
|
||||||
use headers::Cookie;
|
|
||||||
use oauth2::{
|
|
||||||
basic::BasicClient, reqwest::http_client, AuthUrl, AuthorizationCode, ClientId, ClientSecret,
|
|
||||||
CsrfToken, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RevocationUrl, Scope,
|
|
||||||
TokenResponse, TokenUrl,
|
|
||||||
};
|
|
||||||
|
|
||||||
use chrono::Utc;
|
|
||||||
use sqlx::SqlitePool;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use super::{AppError, UserData};
|
|
||||||
|
|
||||||
fn get_client(hostname: String) -> Result<BasicClient, AppError> {
|
|
||||||
let google_client_id = ClientId::new(var("GOOGLE_CLIENT_ID")?);
|
|
||||||
let google_client_secret = ClientSecret::new(var("GOOGLE_CLIENT_SECRET")?);
|
|
||||||
let auth_url = AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string())
|
|
||||||
.map_err(|_| "OAuth: invalid authorization endpoint URL")?;
|
|
||||||
let token_url = TokenUrl::new("https://www.googleapis.com/oauth2/v3/token".to_string())
|
|
||||||
.map_err(|_| "OAuth: invalid token endpoint URL")?;
|
|
||||||
|
|
||||||
let protocol = if hostname.starts_with("localhost") || hostname.starts_with("127.0.0.1") {
|
|
||||||
"http"
|
|
||||||
} else {
|
|
||||||
"https"
|
|
||||||
};
|
|
||||||
|
|
||||||
let redirect_url = format!("{}://{}/google_auth_return", protocol, hostname);
|
|
||||||
|
|
||||||
// Set up the config for the Google OAuth2 process.
|
|
||||||
let client = BasicClient::new(
|
|
||||||
google_client_id,
|
|
||||||
Some(google_client_secret),
|
|
||||||
auth_url,
|
|
||||||
Some(token_url),
|
|
||||||
)
|
|
||||||
.set_redirect_uri(RedirectUrl::new(redirect_url).map_err(|_| "OAuth: invalid redirect URL")?)
|
|
||||||
.set_revocation_uri(
|
|
||||||
RevocationUrl::new("https://oauth2.googleapis.com/revoke".to_string())
|
|
||||||
.map_err(|_| "OAuth: invalid revocation endpoint URL")?,
|
|
||||||
);
|
|
||||||
Ok(client)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn login(
|
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
|
||||||
Query(mut params): Query<HashMap<String, String>>,
|
|
||||||
State(db_pool): State<SqlitePool>,
|
|
||||||
Host(hostname): Host,
|
|
||||||
) -> Result<Redirect, AppError> {
|
|
||||||
|
|
||||||
if user_data.is_some() {
|
|
||||||
// check if already authenticated
|
|
||||||
return Ok(Redirect::to("/"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let return_url = params
|
|
||||||
.remove("return_url")
|
|
||||||
.unwrap_or_else(|| "/".to_string());
|
|
||||||
// TODO: check if return_url is valid
|
|
||||||
|
|
||||||
let client = get_client(hostname)?;
|
|
||||||
|
|
||||||
let (pkce_code_challenge, pkce_code_verifier) = PkceCodeChallenge::new_random_sha256();
|
|
||||||
|
|
||||||
let (authorize_url, csrf_state) = client
|
|
||||||
.authorize_url(CsrfToken::new_random)
|
|
||||||
.add_scope(Scope::new(
|
|
||||||
"https://www.googleapis.com/auth/userinfo.email".to_string(),
|
|
||||||
))
|
|
||||||
.add_scope(Scope::new(
|
|
||||||
"https://www.googleapis.com/auth/userinfo.profile".to_string(),
|
|
||||||
))
|
|
||||||
.set_pkce_challenge(pkce_code_challenge)
|
|
||||||
.url();
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO oauth2_state_storage (csrf_state, pkce_code_verifier, return_url) VALUES (?, ?, ?);",
|
|
||||||
)
|
|
||||||
.bind(csrf_state.secret())
|
|
||||||
.bind(pkce_code_verifier.secret())
|
|
||||||
.bind(return_url)
|
|
||||||
.execute(&db_pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Redirect::to(authorize_url.as_str()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn google_auth_return(
|
|
||||||
Query(mut params): Query<HashMap<String, String>>,
|
|
||||||
State(db_pool): State<SqlitePool>,
|
|
||||||
Host(hostname): Host,
|
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
|
||||||
let state = CsrfToken::new(params.remove("state").ok_or("OAuth: without state")?);
|
|
||||||
let code = AuthorizationCode::new(params.remove("code").ok_or("OAuth: without code")?);
|
|
||||||
|
|
||||||
let query: (String, String) = sqlx::query_as(
|
|
||||||
r#"DELETE FROM oauth2_state_storage WHERE csrf_state = ? RETURNING pkce_code_verifier,return_url"#,
|
|
||||||
)
|
|
||||||
.bind(state.secret())
|
|
||||||
.fetch_one(&db_pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Alternative:
|
|
||||||
// let query: (String, String) = sqlx::query_as(
|
|
||||||
// r#"SELECT pkce_code_verifier,return_url FROM oauth2_state_storage WHERE csrf_state = ?"#,
|
|
||||||
// )
|
|
||||||
// .bind(state.secret())
|
|
||||||
// .fetch_one(&db_pool)
|
|
||||||
// .await?;
|
|
||||||
// let _ = sqlx::query("DELETE FROM oauth2_state_storage WHERE csrf_state = ?")
|
|
||||||
// .bind(state.secret())
|
|
||||||
// .execute(&db_pool)
|
|
||||||
// .await;
|
|
||||||
|
|
||||||
let pkce_code_verifier = query.0;
|
|
||||||
let return_url = query.1;
|
|
||||||
let pkce_code_verifier = PkceCodeVerifier::new(pkce_code_verifier);
|
|
||||||
|
|
||||||
// Exchange the code with a token.
|
|
||||||
let client = get_client(hostname)?;
|
|
||||||
let token_response = tokio::task::spawn_blocking(move || {
|
|
||||||
client
|
|
||||||
.exchange_code(code)
|
|
||||||
.set_pkce_verifier(pkce_code_verifier)
|
|
||||||
.request(http_client)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| "OAuth: exchange_code failure")?
|
|
||||||
.map_err(|_| "OAuth: tokio spawn blocking failure")?;
|
|
||||||
let access_token = token_response.access_token().secret();
|
|
||||||
|
|
||||||
// Get user info from Google
|
|
||||||
let url =
|
|
||||||
"https://www.googleapis.com/oauth2/v2/userinfo?oauth_token=".to_owned() + access_token;
|
|
||||||
let body = reqwest::get(url)
|
|
||||||
.await
|
|
||||||
.map_err(|_| "OAuth: reqwest failed to query userinfo")?
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.map_err(|_| "OAuth: reqwest received invalid userinfo")?;
|
|
||||||
let mut body: serde_json::Value =
|
|
||||||
serde_json::from_str(body.as_str()).map_err(|_| "OAuth: Serde failed to parse userinfo")?;
|
|
||||||
let email = body["email"]
|
|
||||||
.take()
|
|
||||||
.as_str()
|
|
||||||
.ok_or("OAuth: Serde failed to parse email address")?
|
|
||||||
.to_owned();
|
|
||||||
let name = body["name"]
|
|
||||||
.take()
|
|
||||||
.as_str()
|
|
||||||
.ok_or("OAuth: Serde failed to parse email address")?
|
|
||||||
.to_owned();
|
|
||||||
let family_name = body["family_name"]
|
|
||||||
.take()
|
|
||||||
.as_str()
|
|
||||||
.ok_or("OAuth: Serde failed to parse email address")?
|
|
||||||
.to_owned();
|
|
||||||
let given_name = body["given_name"]
|
|
||||||
.take()
|
|
||||||
.as_str()
|
|
||||||
.ok_or("OAuth: Serde failed to parse email address")?
|
|
||||||
.to_owned();
|
|
||||||
let verified_email = body["verified_email"]
|
|
||||||
.take()
|
|
||||||
.as_bool()
|
|
||||||
.ok_or("OAuth: Serde failed to parse verified_email")?;
|
|
||||||
if !verified_email {
|
|
||||||
return Err(AppError::new("OAuth: email address is not verified".to_owned())
|
|
||||||
.with_user_message("Your email address is not verified. Please verify your email address with Google and try again.".to_owned()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user exists in database
|
|
||||||
// If not, create a new user
|
|
||||||
let query: Result<(i64,), _> = sqlx::query_as(r#"SELECT id FROM users WHERE email=?"#)
|
|
||||||
.bind(email.as_str())
|
|
||||||
.fetch_one(&db_pool)
|
|
||||||
.await;
|
|
||||||
let user_id = if let Ok(query) = query {
|
|
||||||
query.0
|
|
||||||
} else {
|
|
||||||
let query: (i64,) = sqlx::query_as("INSERT INTO users (email, name, family_name, given_name) VALUES (?, ?, ?, ?) RETURNING id")
|
|
||||||
.bind(email)
|
|
||||||
.bind(name)
|
|
||||||
.bind(family_name)
|
|
||||||
.bind(given_name)
|
|
||||||
.fetch_one(&db_pool)
|
|
||||||
.await?;
|
|
||||||
query.0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create a session for the user
|
|
||||||
let session_token_p1 = Uuid::new_v4().to_string();
|
|
||||||
let session_token_p2 = Uuid::new_v4().to_string();
|
|
||||||
let session_token = [session_token_p1.as_str(), "_", session_token_p2.as_str()].concat();
|
|
||||||
let headers = axum::response::AppendHeaders([(
|
|
||||||
axum::http::header::SET_COOKIE,
|
|
||||||
"session_token=".to_owned()
|
|
||||||
+ &*session_token
|
|
||||||
+ "; path=/; httponly; secure; samesite=strict",
|
|
||||||
)]);
|
|
||||||
let now = Utc::now().timestamp();
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO user_sessions
|
|
||||||
(session_token_p1, session_token_p2, user_id, created_at, expires_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?);",
|
|
||||||
)
|
|
||||||
.bind(session_token_p1)
|
|
||||||
.bind(session_token_p2)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(now)
|
|
||||||
.bind(now + 60 * 60 * 24)
|
|
||||||
.execute(&db_pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok((headers, Redirect::to(return_url.as_str())))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn logout(
|
|
||||||
cookie: Option<TypedHeader<Cookie>>,
|
|
||||||
State(db_pool): State<SqlitePool>,
|
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
|
||||||
if let Some(cookie) = cookie {
|
|
||||||
if let Some(session_token) = cookie.get("session_token") {
|
|
||||||
let session_token: Vec<&str> = session_token.split('_').collect();
|
|
||||||
let _ = sqlx::query("DELETE FROM user_sessions WHERE session_token_1 = ?")
|
|
||||||
.bind(session_token[0])
|
|
||||||
.execute(&db_pool)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let headers = axum::response::AppendHeaders([(
|
|
||||||
axum::http::header::SET_COOKIE,
|
|
||||||
"session_token=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT",
|
|
||||||
)]);
|
|
||||||
Ok((headers, Redirect::to("/")))
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +1,165 @@
|
||||||
use askama_axum::Template;
|
use askama_axum::{Response, Template};
|
||||||
use axum::{extract::{Path, State}, response::IntoResponse, Extension};
|
use axum::{
|
||||||
use http::Request;
|
extract::{Path, State},
|
||||||
|
response::{Html, IntoResponse, Redirect},
|
||||||
|
Extension,
|
||||||
|
};
|
||||||
|
use http::StatusCode;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::{HtmlTemplate, UserData};
|
use crate::{
|
||||||
|
middlewares::is_authorized,
|
||||||
|
user::{get_other_roles_display, get_user_roles_display},
|
||||||
|
UserData,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "profile.html")]
|
#[template(path = "profile.html")]
|
||||||
struct ProfileTemplate {
|
struct ProfileTemplate {
|
||||||
logged_in: bool,
|
logged_in: bool,
|
||||||
name: String,
|
name: String,
|
||||||
user: UserData
|
user: UserData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "user.html")]
|
||||||
|
struct UserProfileTemplate {
|
||||||
|
logged_in: bool,
|
||||||
|
name: String,
|
||||||
|
user: UserData,
|
||||||
|
user_roles: Vec<crate::user::UserRolesDisplay>,
|
||||||
|
non_user_roles: Vec<crate::user::UserRolesDisplay>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HtmlTemplate<T>(T);
|
||||||
|
|
||||||
|
impl<T> IntoResponse for HtmlTemplate<T>
|
||||||
|
where
|
||||||
|
T: Template,
|
||||||
|
{
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
match self.0.render() {
|
||||||
|
Ok(html) => Html(html).into_response(),
|
||||||
|
Err(err) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to render template. Error: {}", err),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "index.html")]
|
||||||
|
struct IndexTemplate {
|
||||||
|
logged_in: bool,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "dashboard.html")]
|
||||||
|
struct DashboardTemplate {
|
||||||
|
logged_in: bool,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn index(
|
||||||
|
State(db_pool): State<SqlitePool>,
|
||||||
|
Extension(user_data): Extension<Option<UserData>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let user_name = user_data.as_ref().map(|s| s.name.clone());
|
||||||
|
let logged_in = user_name.is_some();
|
||||||
|
let name = user_name.unwrap_or_default();
|
||||||
|
|
||||||
|
if is_authorized("/dashboard", user_data, db_pool).await {
|
||||||
|
Redirect::to("/dashboard").into_response()
|
||||||
|
} else {
|
||||||
|
let template = IndexTemplate { logged_in, name };
|
||||||
|
HtmlTemplate(template).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn dashboard(
|
||||||
|
State(db_pool): State<SqlitePool>,
|
||||||
|
Extension(user_data): Extension<Option<UserData>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let user_name = user_data.as_ref().map(|s| s.name.clone());
|
||||||
|
let logged_in = user_name.is_some();
|
||||||
|
let name = user_name.unwrap_or_default();
|
||||||
|
|
||||||
|
if is_authorized("/dashboard", user_data, db_pool).await {
|
||||||
|
let template = DashboardTemplate { logged_in, name };
|
||||||
|
HtmlTemplate(template).into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the profile page.
|
/// Handles the profile page.
|
||||||
pub async fn profile<T>(
|
pub async fn profile(
|
||||||
|
State(db_pool): State<SqlitePool>,
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
Extension(user_data): Extension<Option<UserData>>,
|
||||||
_request: Request<T>,
|
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// Extract the user's name from the user data.
|
// Extract the user's name from the user data.
|
||||||
let user_name = user_data.as_ref().map(|s| s.name.clone());
|
let user_name = user_data.as_ref().map(|s| s.name.clone());
|
||||||
let logged_in = user_data.is_some();
|
let logged_in = user_name.is_some();
|
||||||
let name = user_name.unwrap_or_default();
|
let name = user_name.unwrap_or_default();
|
||||||
|
|
||||||
// Extract the user data.
|
|
||||||
let user = user_data.as_ref().unwrap();
|
|
||||||
|
|
||||||
// Create the profile template.
|
if logged_in {
|
||||||
let template = ProfileTemplate { logged_in, name, user: user.clone() };
|
// Extract the user data.
|
||||||
return HtmlTemplate(template)
|
let user = user_data.as_ref().unwrap().clone();
|
||||||
|
|
||||||
|
if is_authorized("/profile", user_data, db_pool).await {
|
||||||
|
// Create the profile template.
|
||||||
|
let template = ProfileTemplate {
|
||||||
|
logged_in,
|
||||||
|
name,
|
||||||
|
user: user.clone(),
|
||||||
|
};
|
||||||
|
return HtmlTemplate(template).into_response();
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_profile<T>(
|
pub async fn user_profile(
|
||||||
Path(user_id): Path<i64>,
|
Path(user_id): Path<i64>,
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<SqlitePool>,
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
Extension(user_data): Extension<Option<UserData>>,
|
||||||
_request: Request<T>,
|
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// Extract the user's name from the user data.
|
// Extract the user's name from the user data.
|
||||||
let user_name = user_data.as_ref().map(|s| s.name.clone());
|
let user_name = user_data.as_ref().map(|s| s.name.clone());
|
||||||
let logged_in = user_data.is_some();
|
let logged_in = user_data.is_some();
|
||||||
let name = user_name.unwrap_or_default();
|
let name = user_name.unwrap_or_default();
|
||||||
|
|
||||||
// Extract the user data.
|
|
||||||
let user = sqlx::query_as!(
|
|
||||||
UserData,
|
|
||||||
"SELECT * FROM users WHERE id = ?",
|
|
||||||
user_id
|
|
||||||
)
|
|
||||||
.fetch_one(&db_pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Create the profile template.
|
// Extract the user data.
|
||||||
let template = ProfileTemplate { logged_in, name, user: user };
|
let user = sqlx::query_as!(UserData, "SELECT * FROM users WHERE id = ?", user_id)
|
||||||
return HtmlTemplate(template)
|
.fetch_one(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if is_authorized("/users", user_data, db_pool.clone()).await {
|
||||||
|
// Get user roles
|
||||||
|
let user_roles = get_user_roles_display(user_id, &db_pool.clone()).await;
|
||||||
|
|
||||||
|
// Get roles user does not have
|
||||||
|
let non_user_roles = get_other_roles_display(user_id, &db_pool.clone()).await;
|
||||||
|
|
||||||
|
// Create the profile template.
|
||||||
|
let template = UserProfileTemplate {
|
||||||
|
logged_in,
|
||||||
|
name,
|
||||||
|
user: user,
|
||||||
|
user_roles,
|
||||||
|
non_user_roles,
|
||||||
|
};
|
||||||
|
return HtmlTemplate(template).into_response();
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|
@ -62,24 +167,85 @@ pub async fn profile<T>(
|
||||||
struct UserAdminTemplate {
|
struct UserAdminTemplate {
|
||||||
logged_in: bool,
|
logged_in: bool,
|
||||||
name: String,
|
name: String,
|
||||||
users: Vec<UserData>
|
users: Vec<UserData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn useradmin<T>(
|
pub async fn useradmin(
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
Extension(user_data): Extension<Option<UserData>>,
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<SqlitePool>,
|
||||||
_request: Request<T>,
|
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let user_name = user_data.as_ref().map(|s| s.name.clone());
|
||||||
let user_email = user_data.map(|s| s.email);
|
let logged_in = user_name.is_some();
|
||||||
let logged_in = user_email.is_some();
|
let name = user_name.unwrap_or_default();
|
||||||
let name = user_email.unwrap_or_default();
|
|
||||||
|
|
||||||
let users = sqlx::query_as::<_, UserData>("SELECT * FROM users")
|
let users = sqlx::query_as::<_, UserData>("SELECT * FROM users")
|
||||||
.fetch_all(&db_pool)
|
.fetch_all(&db_pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let template = UserAdminTemplate { logged_in, name, users };
|
if is_authorized("/useradmin", user_data, db_pool).await {
|
||||||
|
let template = UserAdminTemplate {
|
||||||
|
logged_in,
|
||||||
|
name,
|
||||||
|
users,
|
||||||
|
};
|
||||||
|
HtmlTemplate(template).into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "about.html")]
|
||||||
|
struct AboutTemplate {
|
||||||
|
logged_in: bool,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn about(Extension(user_data): Extension<Option<UserData>>) -> impl IntoResponse {
|
||||||
|
let user_name = user_data.map(|s| s.name);
|
||||||
|
let logged_in = user_name.is_some();
|
||||||
|
let name = user_name.unwrap_or_default();
|
||||||
|
|
||||||
|
let template = AboutTemplate { logged_in, name };
|
||||||
HtmlTemplate(template)
|
HtmlTemplate(template)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "contactus.html")]
|
||||||
|
struct ContactTemplate {
|
||||||
|
logged_in: bool,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn contact(Extension(user_data): Extension<Option<UserData>>) -> impl IntoResponse {
|
||||||
|
let user_name = user_data.map(|s| s.name);
|
||||||
|
let logged_in = user_name.is_some();
|
||||||
|
let name = user_name.unwrap_or_default();
|
||||||
|
|
||||||
|
let template = ContactTemplate { logged_in, name };
|
||||||
|
HtmlTemplate(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "cottagecalendar.html")]
|
||||||
|
struct CottageCalendarTemplate {
|
||||||
|
logged_in: bool,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cottagecalendar(
|
||||||
|
Extension(user_data): Extension<Option<UserData>>,
|
||||||
|
State(db_pool): State<SqlitePool>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let user_name = user_data.as_ref().map(|s| s.name.clone());
|
||||||
|
let logged_in = user_name.is_some();
|
||||||
|
let name = user_name.unwrap_or_default();
|
||||||
|
|
||||||
|
if is_authorized("/cottagecalendar", user_data, db_pool).await {
|
||||||
|
let template = CottageCalendarTemplate { logged_in, name };
|
||||||
|
HtmlTemplate(template).into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
use askama_axum::IntoResponse;
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
response::Redirect,
|
||||||
|
Extension,
|
||||||
|
};
|
||||||
|
use chrono::Utc;
|
||||||
|
///User related structs and functions
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{prelude::FromRow, SqlitePool};
|
||||||
|
|
||||||
|
use crate::middlewares::is_authorized;
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Debug, FromRow, Serialize, Deserialize)]
|
||||||
|
pub struct UserData {
|
||||||
|
pub id: i64,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub created_by: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
pub updated_by: i64,
|
||||||
|
pub email: String,
|
||||||
|
pub name: String,
|
||||||
|
pub family_name: String,
|
||||||
|
pub given_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct RoleData {
|
||||||
|
pub id: i64,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub created_by: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
pub updated_by: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Debug, FromRow, Serialize, Deserialize)]
|
||||||
|
pub struct UserRoles {
|
||||||
|
pub id: i64,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub created_by: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
pub updated_by: i64,
|
||||||
|
pub user_id: i64,
|
||||||
|
pub role_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Debug, FromRow, Serialize, Deserialize)]
|
||||||
|
pub struct UserRolesDisplay {
|
||||||
|
pub id: i64,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub created_by: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
pub updated_by: i64,
|
||||||
|
pub user_id: i64,
|
||||||
|
pub user_name: String,
|
||||||
|
pub role_id: i64,
|
||||||
|
pub role_name: String,
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
pub async fn get_user_roles(user_id: i64, db_pool: &SqlitePool) -> Vec<UserRoles> {
|
||||||
|
// Get user roles
|
||||||
|
let user_roles = sqlx::query_as(
|
||||||
|
r#"SELECT id, created_at, created_by, updated_at, updated_by, user_id, role_id FROM user_roles WHERE user_id = ?"#
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
user_roles
|
||||||
|
} */
|
||||||
|
|
||||||
|
pub async fn get_user_roles_display(user_id: i64, db_pool: &SqlitePool) -> Vec<UserRolesDisplay> {
|
||||||
|
// Get user roles
|
||||||
|
let user_roles = sqlx::query_as(
|
||||||
|
r#"select ur.id, u.id as user_id, u.name as user_name, r.id as role_id, r.name as role_name, r.created_at, r.created_by, r.updated_at, r.updated_by from roles r join user_roles ur on ur.role_id = r.id join users u on u.id = ur.user_id WHERE ur.user_id = ?"#
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
user_roles
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_other_roles_display(user_id: i64, db_pool: &SqlitePool) -> Vec<UserRolesDisplay> {
|
||||||
|
// Get roles user does not have
|
||||||
|
let user_roles = sqlx::query_as(
|
||||||
|
r#"select 0 as id, r.created_at, r.created_by, r.updated_at, r.updated_by, ? as user_id, '' as user_name, r.id as role_id, r.name as role_name from roles r where r.id not in (select ur.role_id from user_roles ur where ur.user_id = ?)"#
|
||||||
|
)
|
||||||
|
.bind(user_id.clone())
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
user_roles
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_user_role(
|
||||||
|
Path((user_id, role_id)): Path<(i64, i64)>,
|
||||||
|
State(db_pool): State<SqlitePool>,
|
||||||
|
Extension(user_data): Extension<Option<UserData>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if is_authorized("/roles", user_data.clone(), db_pool.clone()).await {
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
|
||||||
|
println!("Adding role {} to user {}", role_id, user_id);
|
||||||
|
|
||||||
|
sqlx::query("INSERT INTO user_roles (created_at, created_by, updated_at, updated_by, user_id, role_id) VALUES (?, ?, ?, ?, ?, ?)")
|
||||||
|
.bind(now)// Created now
|
||||||
|
.bind(user_data.as_ref().unwrap().id)// Created by current user
|
||||||
|
.bind(now) // Updated now
|
||||||
|
.bind(user_data.as_ref().unwrap().id) // Updated by current user
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(role_id)
|
||||||
|
.execute(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_user_role(
|
||||||
|
Path(user_role_id): Path<i64>,
|
||||||
|
State(db_pool): State<SqlitePool>,
|
||||||
|
Extension(user_data): Extension<Option<UserData>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if is_authorized("/roles", user_data, db_pool.clone()).await {
|
||||||
|
sqlx::query("DELETE FROM user_roles WHERE id = ?")
|
||||||
|
.bind(user_role_id)
|
||||||
|
.execute(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}About{% endblock %}
|
{% block title %}About{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
This is a demo OAuth website.
|
<h1>Welcome to the family!</h1>
|
||||||
|
<p>This site is dedicated to the Jean-Marie/Forth/Canavan/Romano family</p>
|
||||||
|
<p>We will have family events, pictures, and links to interesting places</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row align-items-stretch">
|
||||||
|
<div id="menu" class="col-md-2 bg-light">
|
||||||
|
<!-- internal menu -->
|
||||||
|
<h2>Menu</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/dashboard">Web links</a></li>
|
||||||
|
<li><a href="/useradmin">User Administration</a></li>
|
||||||
|
<li><a href="/cottagecalendar">Cottage Calendar</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
{% block center %}{% endblock center %}
|
||||||
|
</div>
|
||||||
|
<div id="events" class="col-2 bg-light">
|
||||||
|
<!-- events -->
|
||||||
|
<h2>Events</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}About{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Contact Us</h1>
|
||||||
|
<p>If you would like to have access to the website, use the login menu. At this time it will require you to login with a Google account. Once your account has been verified, you will be given access to the dashboard</p>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{% extends "authorized.html" %}
|
||||||
|
{% block center %}
|
||||||
|
<h1>Cottage Calendar</h1>
|
||||||
|
<div class="ratio ratio-4x3">
|
||||||
|
<!--<iframe src="https://calendar.google.com/calendar/embed?src=jeanmarie.cottage%40gmail.com&ctz=America%2FToronto" style="border: 0" width="800" height="400" frameborder="0" scrolling="no"></iframe>-->
|
||||||
|
<iframe width="300" height="430" src="https://nextcloud.jean-marie.ca/index.php/apps/calendar/embed/c3AGT6MXBPs8tzAC"></iframe>
|
||||||
|
</div>
|
||||||
|
{% endblock center %}
|
||||||
|
|
@ -1,17 +1,22 @@
|
||||||
<div class="container py-5 h-100">
|
{% extends "authorized.html" %}
|
||||||
<br>
|
{% block center %}
|
||||||
<p>This will be the private information area for the extended Jean-Marie family.</p>
|
<p>This will be the private information area for the extended Jean-Marie family.</p>
|
||||||
<div>
|
<div>
|
||||||
<h2>Web links</h2>
|
<h2>Web links</h2>
|
||||||
<h3>Fonts</h3>
|
<h3>TLC Creations</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="https://fonts.google.com">Google fonts</a></li>
|
<li><a href="https://www.tlccreations.ca" target="_blank" rel="noopener noreferrer">TLC Creations</a></li>
|
||||||
<li><a href="https://www.fontspace.com">Font Space</a></li>
|
</ul>
|
||||||
</ul>
|
<h3>Fonts</h3>
|
||||||
<h3>Family tree</h3>
|
<ul>
|
||||||
<ul>
|
<li><a href="https://fonts.google.com" target="_blank" rel="noopener noreferrer">Google fonts</a></li>
|
||||||
<li><a href="https://www.ancestry.com">Ancestry</a></li>
|
<li><a href="https://www.fontspace.com" target="_blank" rel="noopener noreferrer">Font Space</a></li>
|
||||||
<li><a href="https://www.geni.com">Geni</a></li>
|
</ul>
|
||||||
</ul>
|
<h3>Family tree</h3>
|
||||||
</div>
|
<ul>
|
||||||
|
<li><a href="https://www.ancestry.ca" target="_blank" rel="noopener noreferrer">Ancestry</a></li>
|
||||||
|
<li><a href="https://www.geni.com" target="_blank" rel="noopener noreferrer">Geni</a></li>
|
||||||
|
<li><a href="http://www.tracingroots.ca/" target="_blank" rel="noopener noreferrer">Tracing Roots - Forth Family Tree</a></li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock center %}
|
||||||
|
|
@ -1,43 +1,11 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if logged_in %}
|
{% if logged_in %}
|
||||||
<div class="container-fluid">
|
<!-- Redirect to dashboard -->
|
||||||
<div class="row align-items-stretch">
|
<!-- Only if user account is authorized -->
|
||||||
<div id="menu" class="col-md-2 bg-light">
|
<h2>Your account has not yet been verified</h2>
|
||||||
<!-- internal menu -->
|
|
||||||
<h2>Menu</h2>
|
|
||||||
<ul>
|
|
||||||
<li>Web links</li>
|
|
||||||
<li><a href="/useradmin">User Administration</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="col-8">
|
|
||||||
<p>This will be the private information area for the extended Jean-Marie family.</p>
|
|
||||||
<div>
|
|
||||||
<h2>Web links</h2>
|
|
||||||
<h3>TLC Creations</h3>
|
|
||||||
<ul>
|
|
||||||
<li><a href="https://www.tlccreations.ca">TLC Creations</a></li>
|
|
||||||
</ul>
|
|
||||||
<h3>Fonts</h3>
|
|
||||||
<ul>
|
|
||||||
<li><a href="https://fonts.google.com">Google fonts</a></li>
|
|
||||||
<li><a href="https://www.fontspace.com">Font Space</a></li>
|
|
||||||
</ul>
|
|
||||||
<h3>Family tree</h3>
|
|
||||||
<ul>
|
|
||||||
<li><a href="https://www.ancestry.com">Ancestry</a></li>
|
|
||||||
<li><a href="https://www.geni.com">Geni</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="events" class="col-2 bg-light">
|
|
||||||
<!-- events -->
|
|
||||||
<h2>Events</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
{% endif %}
|
||||||
<!-- Carousel -->
|
<!-- Carousel -->
|
||||||
<div id="demo" class="carousel slide" data-bs-ride="carousel">
|
<div id="demo" class="carousel slide" data-bs-ride="carousel">
|
||||||
|
|
||||||
|
|
@ -81,5 +49,5 @@
|
||||||
<span class="carousel-control-next-icon"></span>
|
<span class="carousel-control-next-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}User Profile{% endblock %}
|
{% block title %}User Profile{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>User Profile</h1>
|
<h1>My Profile</h1>
|
||||||
Full name: {{ user.name }}<br/>
|
Full name: {{ user.name }}<br/>
|
||||||
Given name: {{ user.given_name }}<br/>
|
Given name: {{ user.given_name }}<br/>
|
||||||
Family name: {{ user.family_name }}<br/>
|
Family name: {{ user.family_name }}<br/>
|
||||||
Your email address: {{ user.email }}<br
|
Your email address: {{ user.email }}<br/>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
{% extends "authorized.html" %}
|
||||||
|
{% block title %}User Profile{% endblock %}
|
||||||
|
{% block center %}
|
||||||
|
<h1>User Profile</h1>
|
||||||
|
Full name: {{ user.name }}<br/>
|
||||||
|
Given name: {{ user.given_name }}<br/>
|
||||||
|
Family name: {{ user.family_name }}<br/>
|
||||||
|
Your email address: {{ user.email }}<br/>
|
||||||
|
<br/>
|
||||||
|
<h2>User Roles</h2>
|
||||||
|
<button type="button" class="btn btn-primary">Edit</button>
|
||||||
|
<button type="button" class="btn btn-primary">Add</button>
|
||||||
|
<table class="table table-striped table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">ID</th>
|
||||||
|
<th scope="col">Name</th>
|
||||||
|
<th scope="col"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user_role in user_roles %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/roles/{{ user_role.role_id }}">{{ user_role.id }}</a></td>
|
||||||
|
<td>{{ user_role.role_name }}</td>
|
||||||
|
<td><a href="/roles/{{ user_role.id }}/delete">Delete</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% for non_user_role in non_user_roles %}
|
||||||
|
<tr>
|
||||||
|
<td>New</td>
|
||||||
|
<td>{{ non_user_role.role_name }}</td>
|
||||||
|
<td><a href="/roles/{{ non_user_role.user_id }}/{{ non_user_role.role_id }}/add">Add</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock center %}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{% extends "base.html" %}
|
{% extends "authorized.html" %}
|
||||||
{% block title %}User Profile{% endblock %}
|
{% block title %}User Administration{% endblock %}
|
||||||
{% block content %}
|
{% block center %}
|
||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
<table class="table table-striped">
|
<table class="table table-striped table-bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">ID</th>
|
<th scope="col">ID</th>
|
||||||
|
|
@ -20,4 +20,4 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% endblock %}
|
{% endblock center %}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
cargo build --release
|
||||||
|
ssh www@192.168.59.11 'pkill jean-marie'
|
||||||
|
scp -i id_rsa target/release/jean-marie www@192.168.59.11:/opt/jean-marie
|
||||||
|
scp -i id_rsa .env www@192.168.59.11:/opt/jean-marie
|
||||||
|
scp -i id_rsa -r templates www@192.168.59.11:/opt/jean-marie
|
||||||
|
ssh www@192.168.59.11 'cd /opt/jean-marie && ./jean-marie&'
|
||||||
Loading…
Reference in New Issue