summaryrefslogtreecommitdiffstats
path: root/src/auth.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/auth.rs')
-rw-r--r--src/auth.rs194
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 @@
1use argon2::{
2 Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
3 password_hash::{SaltString, rand_core::OsRng},
4};
5use axum::{
6 extract::{FromRequestParts, Request, State},
7 http::{StatusCode, request::Parts},
8 middleware::Next,
9 response::{IntoResponse, Redirect, Response},
10};
11use rusqlite::params;
12use serde::Deserialize;
13use uuid::Uuid;
14
15use crate::models::Account;
16use crate::routes::AppState;
17
18#[derive(Debug, Deserialize)]
19pub struct LoginForm {
20 pub username: String,
21 pub password: String,
22}
23
24const SESSION_COOKIE: &str = "mcui_session";
25
26pub 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
32pub 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
39pub 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
95pub 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)]
151pub struct CurrentUser(pub Account);
152
153impl<S> FromRequestParts<S> for CurrentUser
154where
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
169pub 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}