This commit is contained in:
nora 2025-07-13 01:23:09 +02:00
parent c789f7ad15
commit 730cf9d176
5 changed files with 150 additions and 18 deletions

View file

@ -1,7 +1,11 @@
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT NOT NULL PRIMARY KEY,
session_public_id INTEGER NOT NULL PRIMARY KEY,
session_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
created INTEGER NOT NULL,
user_agent TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) STRICT;
CREATE INDEX sessions_session_public_id ON sessions(session_public_id);

View file

@ -8,7 +8,7 @@ use askama::Template;
use axum::{
Form, Router,
extract::State,
http::StatusCode,
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
};
@ -60,6 +60,8 @@ async fn main() -> Result<()> {
.route("/signup", get(signup).post(signup_post))
.route("/login", get(login).post(login_post))
.route("/2fa", get(list_2fa))
.route("/sessions", get(list_sessions))
.route("/sessions/delete", post(delete_session))
.route("/2fa/delete", post(delete_2fa))
.route("/add-totp", get(add_totp).post(add_totp_post))
.route("/users", get(users))
@ -153,6 +155,54 @@ async fn list_2fa(user: UserSession, State(db): State<Db>) -> Result<impl IntoRe
Ok(Html(Template { devices }.render().unwrap()))
}
async fn list_sessions(
user: UserSession,
State(db): State<Db>,
) -> Result<impl IntoResponse, Response> {
let Some(user) = user.0 else {
return Err(Redirect::to("/").into_response());
};
let sessions = session::find_sessions_for_user(&db, user.user_id)
.await
.map_err(|err| {
error!(?err, "Error fetching sessions");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
})?;
#[derive(askama::Template)]
#[template(path = "sessions.html")]
struct Template {
sessions: Vec<session::SessionForList>,
}
Ok(Html(Template { sessions }.render().unwrap()))
}
#[derive(Deserialize)]
struct DeleteSessionForm {
session_public_id: i64,
}
async fn delete_session(
user: UserSession,
State(db): State<Db>,
Form(form): Form<DeleteSessionForm>,
) -> Result<impl IntoResponse, Response> {
let Some(user) = user.0 else {
return Err(Redirect::to("/").into_response());
};
session::delete_session(&db, user.user_id, form.session_public_id)
.await
.map_err(|err| {
error!(?err, "Failed to delete session");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
})?;
Ok(Redirect::to("/sessions"))
}
#[derive(Deserialize)]
struct Delete2faForm {
device_id: i64,
@ -253,11 +303,17 @@ struct UsernamePasswordForm {
password: String,
}
async fn make_session_cookie_for_user(db: &Db, user_id: i64) -> Result<Cookie<'static>, Response> {
let session = session::create_session(&db, user_id).await.map_err(|err| {
error!(?err, "Failed to create session for user");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
})?;
async fn make_session_cookie_for_user(
db: &Db,
user_id: i64,
user_agent: &str,
) -> Result<Cookie<'static>, Response> {
let session = session::create_session(&db, user_id, user_agent)
.await
.map_err(|err| {
error!(?err, "Failed to create session for user");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
})?;
Ok(Cookie::build((SESSION_ID_COOKIE_NAME, session.0))
.secure(true)
@ -272,6 +328,7 @@ async fn make_session_cookie_for_user(db: &Db, user_id: i64) -> Result<Cookie<'s
}
async fn signup_post(
headers: HeaderMap,
State(db): State<Db>,
jar: CookieJar,
Form(signup): Form<UsernamePasswordForm>,
@ -294,12 +351,18 @@ async fn signup_post(
.into_response());
};
let session_id = make_session_cookie_for_user(&db, user.id).await?;
let user_agent = headers
.get(axum::http::header::USER_AGENT)
.and_then(|v| str::from_utf8(v.as_bytes()).ok())
.unwrap_or("unknown");
let session_id = make_session_cookie_for_user(&db, user.id, user_agent).await?;
Ok((jar.add(session_id), Redirect::to("/")))
}
async fn login_post(
headers: HeaderMap,
State(db): State<Db>,
jar: CookieJar,
Form(login): Form<UsernamePasswordForm>,
@ -315,7 +378,12 @@ async fn login_post(
return Err(Html(LoginTemplate { error: true }.render().unwrap()).into_response());
};
let session_id = make_session_cookie_for_user(&db, user.id).await?;
let user_agent = headers
.get(axum::http::header::USER_AGENT)
.and_then(|v| str::from_utf8(v.as_bytes()).ok())
.unwrap_or("unknown");
let session_id = make_session_cookie_for_user(&db, user.id, user_agent).await?;
Ok((jar.add(session_id), Redirect::to("/")))
}

View file

@ -34,18 +34,48 @@ pub async fn find_session(db: &Db, session_id: &str) -> Result<Option<SessionWit
}
}
pub async fn create_session(db: &Db, user_id: i64) -> Result<SessionId> {
#[derive(Debug, sqlx::FromRow)]
pub struct SessionForList {
pub created: i64,
pub session_public_id: i64,
pub user_agent: String,
}
pub async fn find_sessions_for_user(db: &Db, user_id: i64) -> Result<Vec<SessionForList>> {
sqlx::query_as::<_, SessionForList>(
"select created, session_public_id, user_agent from sessions where user_id = ?",
)
.bind(user_id)
.fetch_all(&db.pool)
.await
.wrap_err("failed to fetch sessions for user")
}
pub async fn delete_session(db: &Db, user_id: i64, session_public_id: i64) -> Result<()> {
sqlx::query("delete from sessions where user_id = ? and session_public_id = ?")
.bind(user_id)
.bind(session_public_id)
.execute(&db.pool)
.await
.wrap_err("failed to delete session")?;
Ok(())
}
pub async fn create_session(db: &Db, user_id: i64, user_agent: &str) -> Result<SessionId> {
let mut session_id = [0_u8; 32];
rand_core::OsRng.fill_bytes(&mut session_id);
let session_id = format!("idpsess_{}", hex::encode(session_id));
sqlx::query("insert into sessions (session_id, user_id, created) values (?, ?, ?)")
.bind(&session_id)
.bind(user_id)
.bind(jiff::Timestamp::now().as_millisecond())
.execute(&db.pool)
.await
.wrap_err("inserting new session")?;
sqlx::query(
"insert into sessions (session_id, user_id, created, user_agent) values (?, ?, ?, ?)",
)
.bind(&session_id)
.bind(user_id)
.bind(jiff::Timestamp::now().as_millisecond())
.bind(user_agent)
.execute(&db.pool)
.await
.wrap_err("inserting new session")?;
Ok(SessionId(session_id))
}
@ -63,7 +93,7 @@ impl FromRequestParts<Db> for UserSession {
let jar = CookieJar::from_headers(&parts.headers);
match jar.get(crate::SESSION_ID_COOKIE_NAME) {
None => Err(StatusCode::FORBIDDEN.into_response()),
None => Ok(UserSession(None)),
Some(cookie) => {
let sess = find_session(&db, cookie.value()).await;
match sess {

View file

@ -22,6 +22,9 @@
<a href="/users">List all users</a>
</div>
{% if let Some(username) = username %}
<div>
<a href="/sessions">List all active sessions your account</a>
</div>
<div>
<a href="/add-totp">Add a new 2FA device to your account</a>
</div>

View file

@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sessions - IDP</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<h1>See all sessions your account</h1>
<a href="/">home</a>
<ul>
{% for session in sessions %}
<li>
{{session.user_agent}} (added
{{jiff::Timestamp::from_millisecond(*session.created).unwrap()
.strftime("%A, %d %B %Y at %I:%M %Q")}})
<form method="post" action="/sessions/delete" class="fake-form">
<input type="hidden" name="session_public_id" value="{{session.session_public_id}}" />
<button type="submit">delete</button>
</form>
</li>
{% endfor %}
</ul>
</body>
</html>