diff options
Diffstat (limited to 'src/auth.rs')
| -rw-r--r-- | src/auth.rs | 194 |
1 files changed, 194 insertions, 0 deletions
diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..8fa62ec --- /dev/null +++ b/src/auth.rs | |||
| @@ -0,0 +1,194 @@ | |||
| 1 | use argon2::{ | ||
| 2 | Argon2, PasswordHash, PasswordHasher, PasswordVerifier, | ||
| 3 | password_hash::{SaltString, rand_core::OsRng}, | ||
| 4 | }; | ||
| 5 | use axum::{ | ||
| 6 | extract::{FromRequestParts, Request, State}, | ||
| 7 | http::{StatusCode, request::Parts}, | ||
| 8 | middleware::Next, | ||
| 9 | response::{IntoResponse, Redirect, Response}, | ||
| 10 | }; | ||
| 11 | use rusqlite::params; | ||
| 12 | use serde::Deserialize; | ||
| 13 | use uuid::Uuid; | ||
| 14 | |||
| 15 | use crate::models::Account; | ||
| 16 | use crate::routes::AppState; | ||
| 17 | |||
| 18 | #[derive(Debug, Deserialize)] | ||
| 19 | pub struct LoginForm { | ||
| 20 | pub username: String, | ||
| 21 | pub password: String, | ||
| 22 | } | ||
| 23 | |||
| 24 | const SESSION_COOKIE: &str = "mcui_session"; | ||
| 25 | |||
| 26 | pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> { | ||
| 27 | let salt = SaltString::generate(&mut OsRng); | ||
| 28 | let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?; | ||
| 29 | Ok(hash.to_string()) | ||
| 30 | } | ||
| 31 | |||
| 32 | pub fn verify_password(password: &str, hash: &str) -> Result<bool, argon2::password_hash::Error> { | ||
| 33 | let parsed = PasswordHash::new(hash)?; | ||
| 34 | Ok(Argon2::default() | ||
| 35 | .verify_password(password.as_bytes(), &parsed) | ||
| 36 | .is_ok()) | ||
| 37 | } | ||
| 38 | |||
| 39 | pub async fn authenticate( | ||
| 40 | State(state): State<AppState>, | ||
| 41 | axum::Form(form): axum::Form<LoginForm>, | ||
| 42 | ) -> Result<impl IntoResponse, (StatusCode, String)> { | ||
| 43 | let conn = state.db.conn(); | ||
| 44 | |||
| 45 | let account = conn | ||
| 46 | .query_row( | ||
| 47 | "SELECT id, username, is_superuser, password_change_required FROM accounts WHERE username = ?1", | ||
| 48 | params![form.username], | ||
| 49 | |row| { | ||
| 50 | Ok(Account { | ||
| 51 | id: row.get(0)?, | ||
| 52 | username: row.get(1)?, | ||
| 53 | is_superuser: row.get::<_, i32>(2)? != 0, | ||
| 54 | password_change_required: row.get::<_, i32>(3)? != 0, | ||
| 55 | }) | ||
| 56 | }, | ||
| 57 | ) | ||
| 58 | .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid username or password".into()))?; | ||
| 59 | |||
| 60 | let hash: String = conn | ||
| 61 | .query_row( | ||
| 62 | "SELECT password FROM accounts WHERE id = ?1", | ||
| 63 | params![account.id], | ||
| 64 | |row| row.get(0), | ||
| 65 | ) | ||
| 66 | .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error".into()))?; | ||
| 67 | |||
| 68 | if !verify_password(&form.password, &hash).unwrap_or(false) { | ||
| 69 | return Err(( | ||
| 70 | StatusCode::UNAUTHORIZED, | ||
| 71 | "Invalid username or password".into(), | ||
| 72 | )); | ||
| 73 | } | ||
| 74 | |||
| 75 | let session_id = Uuid::new_v4().to_string(); | ||
| 76 | conn.execute( | ||
| 77 | "INSERT INTO sessions (id, account_id) VALUES (?1, ?2)", | ||
| 78 | params![session_id, account.id], | ||
| 79 | ) | ||
| 80 | .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Session error".into()))?; | ||
| 81 | drop(conn); | ||
| 82 | |||
| 83 | let cookie_value = | ||
| 84 | format!("{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; Max-Age=86400; SameSite=Lax"); | ||
| 85 | |||
| 86 | let redirect = if account.password_change_required { | ||
| 87 | Redirect::to("/change-password") | ||
| 88 | } else { | ||
| 89 | Redirect::to("/") | ||
| 90 | }; | ||
| 91 | |||
| 92 | Ok(([(axum::http::header::SET_COOKIE, cookie_value)], redirect)) | ||
| 93 | } | ||
| 94 | |||
| 95 | pub async fn require_auth( | ||
| 96 | State(state): State<AppState>, | ||
| 97 | mut request: Request, | ||
| 98 | next: Next, | ||
| 99 | ) -> Result<Response, Response> { | ||
| 100 | let cookies = request | ||
| 101 | .headers() | ||
| 102 | .get(axum::http::header::COOKIE) | ||
| 103 | .and_then(|v| v.to_str().ok()) | ||
| 104 | .unwrap_or(""); | ||
| 105 | |||
| 106 | let session_id = cookies.split(';').map(|s| s.trim()).find_map(|cookie| { | ||
| 107 | if let Some((name, value)) = cookie.split_once('=') { | ||
| 108 | if name.trim() == SESSION_COOKIE { | ||
| 109 | Some(value.trim().to_string()) | ||
| 110 | } else { | ||
| 111 | None | ||
| 112 | } | ||
| 113 | } else { | ||
| 114 | None | ||
| 115 | } | ||
| 116 | }); | ||
| 117 | |||
| 118 | match session_id { | ||
| 119 | Some(id) => { | ||
| 120 | let account = state | ||
| 121 | .db | ||
| 122 | .conn() | ||
| 123 | .query_row( | ||
| 124 | "SELECT a.id, a.username, a.is_superuser, a.password_change_required | ||
| 125 | FROM sessions s JOIN accounts a ON s.account_id = a.id | ||
| 126 | WHERE s.id = ?1", | ||
| 127 | params![id], | ||
| 128 | |row| { | ||
| 129 | Ok(Account { | ||
| 130 | id: row.get(0)?, | ||
| 131 | username: row.get(1)?, | ||
| 132 | is_superuser: row.get::<_, i32>(2)? != 0, | ||
| 133 | password_change_required: row.get::<_, i32>(3)? != 0, | ||
| 134 | }) | ||
| 135 | }, | ||
| 136 | ) | ||
| 137 | .ok(); | ||
| 138 | |||
| 139 | if let Some(account) = account { | ||
| 140 | request.extensions_mut().insert(account); | ||
| 141 | Ok(next.run(request).await) | ||
| 142 | } else { | ||
| 143 | Err(Redirect::to("/login").into_response()) | ||
| 144 | } | ||
| 145 | } | ||
| 146 | None => Err(Redirect::to("/login").into_response()), | ||
| 147 | } | ||
| 148 | } | ||
| 149 | |||
| 150 | #[derive(Clone)] | ||
| 151 | pub struct CurrentUser(pub Account); | ||
| 152 | |||
| 153 | impl<S> FromRequestParts<S> for CurrentUser | ||
| 154 | where | ||
| 155 | S: Send + Sync, | ||
| 156 | { | ||
| 157 | type Rejection = (StatusCode, &'static str); | ||
| 158 | |||
| 159 | async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> { | ||
| 160 | parts | ||
| 161 | .extensions | ||
| 162 | .get::<Account>() | ||
| 163 | .cloned() | ||
| 164 | .map(CurrentUser) | ||
| 165 | .ok_or((StatusCode::UNAUTHORIZED, "Not authenticated")) | ||
| 166 | } | ||
| 167 | } | ||
| 168 | |||
| 169 | pub async fn logout( | ||
| 170 | State(state): State<AppState>, | ||
| 171 | headers: axum::http::HeaderMap, | ||
| 172 | ) -> impl IntoResponse { | ||
| 173 | if let Some(cookie_header) = headers.get(axum::http::header::COOKIE) { | ||
| 174 | if let Ok(cookie_str) = cookie_header.to_str() { | ||
| 175 | for part in cookie_str.split(';') { | ||
| 176 | let part = part.trim(); | ||
| 177 | if let Some((name, value)) = part.split_once('=') { | ||
| 178 | if name.trim() == SESSION_COOKIE { | ||
| 179 | let _ = state | ||
| 180 | .db | ||
| 181 | .conn() | ||
| 182 | .execute("DELETE FROM sessions WHERE id = ?1", params![value.trim()]); | ||
| 183 | } | ||
| 184 | } | ||
| 185 | } | ||
| 186 | } | ||
| 187 | } | ||
| 188 | |||
| 189 | let clear_cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Max-Age=0"); | ||
| 190 | ( | ||
| 191 | [(axum::http::header::SET_COOKIE, clear_cookie)], | ||
| 192 | Redirect::to("/login"), | ||
| 193 | ) | ||
| 194 | } | ||
