add passkey support
This commit is contained in:
+134
-5
@@ -5,7 +5,7 @@ use std::time::Duration;
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{
|
||||
Form, FromRequest, FromRequestParts, Path, Query, Request, State,
|
||||
Form, FromRequest, FromRequestParts, Json, Path, Query, Request, State,
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header, request::Parts},
|
||||
@@ -27,6 +27,7 @@ use crate::domain::{DomainError, SessionUser};
|
||||
use crate::ports::{HubEvent, RealtimeNotifier};
|
||||
use crate::services::{AuthService, InvitationService, ListService, MealService};
|
||||
use crate::views;
|
||||
use crate::webauthn::WebAuthnService;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
@@ -34,6 +35,7 @@ pub struct AppState {
|
||||
pub lists: Arc<ListService>,
|
||||
pub meals: Arc<MealService>,
|
||||
pub invitations: Arc<InvitationService>,
|
||||
pub webauthn: Arc<WebAuthnService>,
|
||||
pub realtime: Arc<dyn RealtimeNotifier>,
|
||||
pub cookie_secure: bool,
|
||||
pub public_base_url: String,
|
||||
@@ -52,10 +54,13 @@ pub enum AppError {
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
match self {
|
||||
AppError::Database(_) => status_html_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
views::error_page("500", "Something went wrong."),
|
||||
),
|
||||
AppError::Database(error) => {
|
||||
error!(%error, "request failed");
|
||||
status_html_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
views::error_page("500", "Something went wrong."),
|
||||
)
|
||||
}
|
||||
AppError::BadRequest(message) => {
|
||||
status_html_response(StatusCode::BAD_REQUEST, views::error_page("400", &message))
|
||||
}
|
||||
@@ -73,6 +78,18 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/login", get(login_page).post(login))
|
||||
.route("/register", get(register_page).post(register))
|
||||
.route("/logout", post(logout))
|
||||
.route("/account", get(account_page))
|
||||
.route("/auth/passkey/register/start", post(passkey_register_start))
|
||||
.route(
|
||||
"/auth/passkey/register/finish",
|
||||
post(passkey_register_finish),
|
||||
)
|
||||
.route("/auth/passkey/login/start", post(passkey_login_start))
|
||||
.route("/auth/passkey/login/finish", post(passkey_login_finish))
|
||||
.route(
|
||||
"/account/passkeys/{passkey_id}/delete",
|
||||
post(delete_passkey),
|
||||
)
|
||||
.route("/lists", get(lists_page).post(create_list))
|
||||
.route("/lists/{list_id}", get(list_page))
|
||||
.route("/lists/{list_id}/items", post(add_item))
|
||||
@@ -235,6 +252,32 @@ struct CategoryForm {
|
||||
csrf: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PasskeyRegisterStartForm {
|
||||
csrf: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PasskeyRegisterFinishForm {
|
||||
csrf: String,
|
||||
response: webauthn_rs::proto::RegisterPublicKeyCredential,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PasskeyLoginStartForm {
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PasskeyLoginFinishForm {
|
||||
response: webauthn_rs::proto::PublicKeyCredential,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DeletePasskeyForm {
|
||||
csrf: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MealForm {
|
||||
name: String,
|
||||
@@ -389,6 +432,92 @@ async fn logout(State(state): State<AppState>, user: CurrentUser) -> Result<Resp
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn account_page(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
) -> Result<Response, AppError> {
|
||||
let passkeys = state.webauthn.list_passkeys(user.session.user.id).await?;
|
||||
Ok(html_response(views::account_page(
|
||||
&user.session.user,
|
||||
&passkeys,
|
||||
&user.session.csrf_token,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn passkey_register_start(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Json(form): Json<PasskeyRegisterStartForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
verify_csrf(&user, &form.csrf)?;
|
||||
let challenge = state
|
||||
.webauthn
|
||||
.start_registration(&user.session.user)
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(Json(challenge).into_response())
|
||||
}
|
||||
|
||||
async fn passkey_register_finish(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Json(form): Json<PasskeyRegisterFinishForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
verify_csrf(&user, &form.csrf)?;
|
||||
state
|
||||
.webauthn
|
||||
.finish_registration(&user.session.user, form.response)
|
||||
.await?;
|
||||
Ok(Redirect::to("/account").into_response())
|
||||
}
|
||||
|
||||
async fn passkey_login_start(
|
||||
State(state): State<AppState>,
|
||||
Json(form): Json<PasskeyLoginStartForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
let email = form.email.trim().to_lowercase();
|
||||
let Some((user, _)) = state.auth.find_user_by_email(email).await? else {
|
||||
return Err(AppError::NotFound);
|
||||
};
|
||||
let challenge = state
|
||||
.webauthn
|
||||
.start_authentication(user.id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(Json(challenge).into_response())
|
||||
}
|
||||
|
||||
async fn passkey_login_finish(
|
||||
State(state): State<AppState>,
|
||||
Json(form): Json<PasskeyLoginFinishForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
let user_id = state
|
||||
.webauthn
|
||||
.resolve_user_id_for_assertion(&form.response)
|
||||
.await?;
|
||||
state
|
||||
.webauthn
|
||||
.finish_authentication(user_id, form.response)
|
||||
.await?;
|
||||
let (session_token, _) = state.auth.create_session_for_user(user_id).await?;
|
||||
let mut response = Redirect::to("/lists").into_response();
|
||||
set_session_cookie(&mut response, &session_token, state.cookie_secure);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn delete_passkey(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(passkey_id): Path<i64>,
|
||||
LoggedForm(form): LoggedForm<DeletePasskeyForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
verify_csrf(&user, &form.csrf)?;
|
||||
state
|
||||
.webauthn
|
||||
.delete_passkey(user.session.user.id, passkey_id)
|
||||
.await?;
|
||||
Ok(Redirect::to("/account").into_response())
|
||||
}
|
||||
|
||||
async fn lists_page(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
|
||||
Reference in New Issue
Block a user