962 lines
28 KiB
Rust
962 lines
28 KiB
Rust
use axum::{
|
|
extract::State,
|
|
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode},
|
|
response::{IntoResponse, Response},
|
|
Json,
|
|
};
|
|
use axum_extra::{
|
|
headers::{authorization::Bearer, Authorization, Cookie},
|
|
typed_header::TypedHeader,
|
|
};
|
|
use chrono::{Duration as ChronoDuration, Utc};
|
|
use diesel::{pg::PgConnection, prelude::*};
|
|
use rand::{rngs::OsRng, RngCore};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use utoipa::{OpenApi, ToSchema};
|
|
use uuid::Uuid;
|
|
|
|
use crate::{
|
|
auth::{
|
|
api_tokens::{find_active_token_by_secret, touch_api_token},
|
|
passkeys::{
|
|
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
|
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
|
},
|
|
AuthenticatedUser,
|
|
},
|
|
error::{AppError, AppResult},
|
|
models::{
|
|
ApiTokenCapability, MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus,
|
|
User, UserSession,
|
|
},
|
|
schema::{
|
|
magic_tokens::dsl as magic_dsl, tenants::dsl as tenant_dsl,
|
|
user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, user_sessions,
|
|
users::dsl,
|
|
},
|
|
state::AppState,
|
|
tenants::{
|
|
apply_tenant_guc, apply_user_guc, apply_user_session_hash, clear_user_guc,
|
|
clear_user_session_hash,
|
|
},
|
|
};
|
|
|
|
use crate::schema::user_sessions::dsl as session_dsl;
|
|
use webauthn_rs::prelude::RegisterPublicKeyCredential;
|
|
|
|
const SESSION_COOKIE_NAME: &str = "refresh_token";
|
|
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct LoginRequest {
|
|
#[serde(default)]
|
|
pub username: String,
|
|
#[serde(default)]
|
|
#[schema(nullable)]
|
|
pub password: Option<String>,
|
|
#[serde(default)]
|
|
#[schema(nullable)]
|
|
pub magic_token: Option<String>,
|
|
#[serde(default)]
|
|
#[schema(nullable)]
|
|
pub preferred_tenant_id: Option<Uuid>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct ApiTokenExchangeRequest {
|
|
pub api_token: String,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, ToSchema)]
|
|
pub struct LoginResponse {
|
|
pub access_token: String,
|
|
pub token_type: String,
|
|
pub expires_in: i64,
|
|
pub tenant: TenantSnippet,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, ToSchema)]
|
|
pub struct TenantSnippet {
|
|
pub id: Uuid,
|
|
pub name: String,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, ToSchema)]
|
|
pub struct TenantSelectionResponse {
|
|
pub access_token: String,
|
|
pub tenants: Vec<TenantSnippet>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, ToSchema)]
|
|
pub struct TenantListResponse {
|
|
pub tenants: Vec<TenantSnippet>,
|
|
}
|
|
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct TenantSelectionRequest {
|
|
pub tenant_id: Uuid,
|
|
}
|
|
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct SignupStartRequest {
|
|
pub username: String,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, ToSchema)]
|
|
pub struct SignupStartResponse {
|
|
pub signup_token: String,
|
|
pub challenge: RegistrationChallengeResponse,
|
|
}
|
|
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct SignupFinishRequest {
|
|
pub signup_token: String,
|
|
#[schema(value_type = Object)]
|
|
pub credential: RegisterPublicKeyCredential,
|
|
#[schema(nullable)]
|
|
pub nickname: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, ToSchema)]
|
|
#[serde(untagged)]
|
|
pub enum LoginResponseVariants {
|
|
Token(LoginResponse),
|
|
Selection(TenantSelectionResponse),
|
|
}
|
|
|
|
#[derive(OpenApi)]
|
|
#[openapi(
|
|
paths(
|
|
login,
|
|
api_token_exchange,
|
|
signup_start,
|
|
signup_finish,
|
|
refresh,
|
|
logout,
|
|
me,
|
|
list_tenants,
|
|
select_tenant,
|
|
passkey_register_start,
|
|
passkey_register_finish,
|
|
passkey_login_start,
|
|
passkey_login_finish,
|
|
),
|
|
components(schemas(
|
|
LoginRequest,
|
|
ApiTokenExchangeRequest,
|
|
SignupStartRequest,
|
|
SignupStartResponse,
|
|
SignupFinishRequest,
|
|
LoginResponse,
|
|
LoginResponseVariants,
|
|
TenantSnippet,
|
|
TenantSelectionResponse,
|
|
TenantSelectionRequest,
|
|
TenantListResponse,
|
|
crate::auth::AuthenticatedUser,
|
|
crate::auth::passkeys::RegistrationChallengeResponse,
|
|
crate::auth::passkeys::AuthenticationChallengeResponse,
|
|
crate::auth::passkeys::PasskeySummary,
|
|
crate::auth::passkeys::PasskeyRegistrationFinishPayload,
|
|
crate::auth::passkeys::PasskeyLoginStartPayload,
|
|
crate::auth::passkeys::PasskeyLoginFinishPayload,
|
|
crate::models::ApiTokenCapability,
|
|
))
|
|
)]
|
|
pub struct AuthApiDoc;
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/auth/login",
|
|
request_body = LoginRequest,
|
|
responses(
|
|
(status = 200, description = "Login succeeded", body = LoginResponseVariants),
|
|
(status = 401, description = "Invalid credentials")
|
|
),
|
|
tag = "Auth"
|
|
)]
|
|
pub async fn login(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<LoginRequest>,
|
|
) -> AppResult<Response> {
|
|
let magic_token = payload
|
|
.magic_token
|
|
.as_ref()
|
|
.map(|value| value.trim())
|
|
.filter(|value| !value.is_empty());
|
|
|
|
if magic_token.is_none() {
|
|
if payload.password.is_some() {
|
|
return Err(AppError::bad_request(
|
|
"password authentication is no longer supported",
|
|
));
|
|
}
|
|
|
|
return Err(AppError::bad_request(
|
|
"magic_token is required for passwordless login",
|
|
));
|
|
}
|
|
|
|
let token_value = magic_token.unwrap();
|
|
|
|
let mut conn = state.db_unscoped()?;
|
|
let username_hint = payload.username.trim();
|
|
let preferred_tenant_id = payload.preferred_tenant_id;
|
|
|
|
magic_token_login(
|
|
&state,
|
|
&mut conn,
|
|
token_value,
|
|
(!username_hint.is_empty()).then_some(username_hint),
|
|
preferred_tenant_id,
|
|
)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/auth/exchange-api-token",
|
|
request_body = ApiTokenExchangeRequest,
|
|
responses((status = 200, description = "Access token issued", body = LoginResponse)),
|
|
tag = "Auth"
|
|
)]
|
|
pub async fn api_token_exchange(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<ApiTokenExchangeRequest>,
|
|
) -> AppResult<Json<LoginResponse>> {
|
|
let secret = payload.api_token.trim();
|
|
if secret.is_empty() {
|
|
return Err(AppError::bad_request("api_token must not be empty"));
|
|
}
|
|
|
|
let mut conn = state.db_unscoped()?;
|
|
|
|
let token = find_active_token_by_secret(&mut conn, None, secret, ApiTokenCapability::Api)?
|
|
.ok_or_else(AppError::unauthorized)?;
|
|
|
|
let user: User = dsl::users.find(token.user_id).first(&mut conn)?;
|
|
|
|
apply_user_guc(&mut conn, user.id)?;
|
|
let membership = memberships_dsl::user_memberships
|
|
.filter(memberships_dsl::user_id.eq(user.id))
|
|
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
|
.select(memberships_dsl::tenant_id)
|
|
.first::<Uuid>(&mut conn)
|
|
.optional()?;
|
|
clear_user_guc(&mut conn)?;
|
|
|
|
if membership.is_none() {
|
|
return Err(AppError::unauthorized());
|
|
}
|
|
|
|
apply_tenant_guc(&mut conn, token.tenant_id)?;
|
|
touch_api_token(&mut conn, token.id)?;
|
|
|
|
let access_token = state
|
|
.jwt
|
|
.generate_token(user.id, token.tenant_id, &user.username)
|
|
.map_err(AppError::from)?;
|
|
|
|
let tenant_name: String = tenant_dsl::tenants
|
|
.find(token.tenant_id)
|
|
.select(tenant_dsl::name)
|
|
.first(&mut conn)
|
|
.map_err(AppError::from)?;
|
|
|
|
let response = LoginResponse {
|
|
access_token,
|
|
token_type: "Bearer".to_string(),
|
|
expires_in: state.config.jwt_expiry_minutes * 60,
|
|
tenant: TenantSnippet {
|
|
id: token.tenant_id,
|
|
name: tenant_name,
|
|
},
|
|
};
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/auth/signup/start",
|
|
request_body = SignupStartRequest,
|
|
responses(
|
|
(status = 200, description = "Signup challenge created", body = SignupStartResponse),
|
|
(status = 400, description = "Invalid signup request"),
|
|
(status = 409, description = "Username already exists")
|
|
),
|
|
tag = "Auth"
|
|
)]
|
|
pub async fn signup_start(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<SignupStartRequest>,
|
|
) -> AppResult<Json<SignupStartResponse>> {
|
|
let username = payload.username.trim();
|
|
if username.is_empty() {
|
|
return Err(AppError::bad_request("username must not be empty"));
|
|
}
|
|
|
|
let mut conn = state.db_unscoped()?;
|
|
let exists: bool = dsl::users
|
|
.filter(dsl::username.eq(username))
|
|
.first::<User>(&mut conn)
|
|
.optional()?
|
|
.is_some();
|
|
if exists {
|
|
return Err(AppError::conflict("username already exists"));
|
|
}
|
|
|
|
let user_id = Uuid::new_v4();
|
|
let challenge = state
|
|
.passkeys
|
|
.as_ref()
|
|
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?
|
|
.start_signup_registration(&mut conn, user_id, username)?;
|
|
|
|
let signup_token = state
|
|
.jwt
|
|
.generate_signup_token(user_id, challenge.challenge_id, username.to_owned())
|
|
.map_err(AppError::from)?;
|
|
|
|
Ok(Json(SignupStartResponse {
|
|
signup_token,
|
|
challenge,
|
|
}))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/auth/signup/finish",
|
|
request_body = SignupFinishRequest,
|
|
responses(
|
|
(status = 200, description = "Signup completed", body = LoginResponseVariants),
|
|
(status = 400, description = "Invalid signup completion"),
|
|
(status = 409, description = "Username already exists")
|
|
),
|
|
tag = "Auth"
|
|
)]
|
|
pub async fn signup_finish(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<SignupFinishRequest>,
|
|
) -> AppResult<Response> {
|
|
let claims = state
|
|
.jwt
|
|
.verify_signup_token(&payload.signup_token)
|
|
.map_err(|_| AppError::unauthorized())?;
|
|
|
|
let mut conn = state.db_unscoped()?;
|
|
|
|
let exists: bool = dsl::users
|
|
.filter(dsl::username.eq(&claims.username))
|
|
.first::<User>(&mut conn)
|
|
.optional()?
|
|
.is_some();
|
|
if exists {
|
|
return Err(AppError::conflict("username already exists"));
|
|
}
|
|
|
|
let service = state
|
|
.passkeys
|
|
.as_ref()
|
|
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
|
|
|
let prepared_passkey =
|
|
service.consume_signup_challenge(&mut conn, claims.challenge_id, &payload.credential)?;
|
|
|
|
let state_clone = state.clone();
|
|
let response = conn.transaction::<Response, AppError, _>(|conn| {
|
|
insert_user(conn, claims.sub, &claims.username)?;
|
|
|
|
let tenant = state_clone.tenants.create_tenant_with_conn(
|
|
conn,
|
|
&claims.username,
|
|
None,
|
|
None,
|
|
TenantStatus::Creating,
|
|
&[claims.sub],
|
|
Some(claims.sub),
|
|
)?;
|
|
|
|
let passkey_insert =
|
|
prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone());
|
|
|
|
diesel::insert_into(passkey_dsl::user_passkeys)
|
|
.values(&passkey_insert)
|
|
.execute(conn)
|
|
.map_err(AppError::from)?;
|
|
|
|
let user: User = dsl::users.find(claims.sub).first(conn)?;
|
|
issue_session(&state_clone, conn, &user, tenant.id)
|
|
})?;
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/auth/refresh",
|
|
responses(
|
|
(status = 200, description = "Refreshed access token", body = LoginResponse),
|
|
(status = 401, description = "Missing or invalid refresh token")
|
|
),
|
|
tag = "Auth"
|
|
)]
|
|
pub async fn refresh(
|
|
State(state): State<AppState>,
|
|
jar: Option<TypedHeader<Cookie>>,
|
|
) -> AppResult<Response> {
|
|
let cookies = jar.ok_or_else(AppError::unauthorized)?;
|
|
let refresh_value = cookies
|
|
.get(SESSION_COOKIE_NAME)
|
|
.ok_or_else(AppError::unauthorized)?;
|
|
|
|
let hashed = hash_session_token(refresh_value);
|
|
let mut conn = state.db_unscoped()?;
|
|
let now = Utc::now();
|
|
let now_naive = now.naive_utc();
|
|
|
|
apply_user_session_hash(&mut conn, &hashed)?;
|
|
let token = match session_dsl::user_sessions
|
|
.filter(session_dsl::token_hash.eq(&hashed))
|
|
.filter(session_dsl::revoked_at.is_null())
|
|
.filter(session_dsl::expires_at.gt(now_naive))
|
|
.first::<UserSession>(&mut conn)
|
|
{
|
|
Ok(token) => token,
|
|
Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()),
|
|
Err(err) => return Err(AppError::from(err)),
|
|
};
|
|
|
|
clear_user_session_hash(&mut conn)?;
|
|
apply_tenant_guc(&mut conn, token.tenant_id)?;
|
|
clear_user_guc(&mut conn)?;
|
|
|
|
diesel::update(session_dsl::user_sessions.filter(session_dsl::id.eq(token.id)))
|
|
.set((
|
|
session_dsl::revoked_at.eq(now_naive),
|
|
session_dsl::updated_at.eq(now_naive),
|
|
))
|
|
.execute(&mut conn)?;
|
|
|
|
let user: User = dsl::users
|
|
.find(token.user_id)
|
|
.first(&mut conn)
|
|
.map_err(AppError::from)?;
|
|
|
|
issue_session(&state, &mut conn, &user, token.tenant_id)
|
|
}
|
|
|
|
fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> {
|
|
let new_user = NewUser {
|
|
id,
|
|
username: username.to_string(),
|
|
};
|
|
|
|
diesel::insert_into(dsl::users)
|
|
.values(&new_user)
|
|
.execute(conn)
|
|
.map(|_| ())
|
|
.map_err(AppError::from)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/auth/select-tenant",
|
|
request_body = TenantSelectionRequest,
|
|
responses((status = 200, description = "Tenant selected", body = LoginResponse)),
|
|
tag = "Auth"
|
|
)]
|
|
pub async fn select_tenant(
|
|
State(state): State<AppState>,
|
|
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
|
Json(payload): Json<TenantSelectionRequest>,
|
|
) -> AppResult<Response> {
|
|
let user_id = match state.jwt.verify_tenant_selector_token(bearer.token()) {
|
|
Ok(claims) => claims.sub,
|
|
Err(_) => state
|
|
.jwt
|
|
.verify_token(bearer.token())
|
|
.map(|claims| claims.sub)
|
|
.map_err(|_| AppError::unauthorized())?,
|
|
};
|
|
|
|
let mut conn = state.db_unscoped()?;
|
|
apply_user_guc(&mut conn, user_id)?;
|
|
|
|
let membership_exists = memberships_dsl::user_memberships
|
|
.filter(memberships_dsl::user_id.eq(user_id))
|
|
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
|
|
.select(memberships_dsl::tenant_id)
|
|
.first::<Uuid>(&mut conn)
|
|
.optional()?;
|
|
|
|
clear_user_guc(&mut conn)?;
|
|
|
|
if membership_exists.is_none() {
|
|
return Err(AppError::unauthorized());
|
|
}
|
|
|
|
let user: User = dsl::users
|
|
.find(user_id)
|
|
.first(&mut conn)
|
|
.map_err(AppError::from)?;
|
|
|
|
issue_session(&state, &mut conn, &user, payload.tenant_id)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/auth/logout",
|
|
responses((status = 204, description = "Session revoked")),
|
|
tag = "Auth"
|
|
)]
|
|
pub async fn logout(
|
|
State(state): State<AppState>,
|
|
user: AuthenticatedUser,
|
|
jar: Option<TypedHeader<Cookie>>,
|
|
) -> AppResult<(HeaderMap, StatusCode)> {
|
|
let mut conn = state.db_for_tenant(user.tenant_id)?;
|
|
let now = Utc::now().naive_utc();
|
|
let mut rows_affected = 0;
|
|
|
|
if let Some(cookies) = jar {
|
|
if let Some(value) = cookies.get(SESSION_COOKIE_NAME) {
|
|
let hashed = hash_session_token(value);
|
|
rows_affected = diesel::update(
|
|
session_dsl::user_sessions
|
|
.filter(session_dsl::token_hash.eq(hashed))
|
|
.filter(session_dsl::user_id.eq(user.user_id))
|
|
.filter(session_dsl::revoked_at.is_null()),
|
|
)
|
|
.set((
|
|
session_dsl::revoked_at.eq(now),
|
|
session_dsl::updated_at.eq(now),
|
|
))
|
|
.execute(&mut conn)
|
|
.unwrap_or(0);
|
|
}
|
|
}
|
|
|
|
if rows_affected == 0 {
|
|
let _ = diesel::update(
|
|
session_dsl::user_sessions
|
|
.filter(session_dsl::user_id.eq(user.user_id))
|
|
.filter(session_dsl::revoked_at.is_null()),
|
|
)
|
|
.set((
|
|
session_dsl::revoked_at.eq(now),
|
|
session_dsl::updated_at.eq(now),
|
|
))
|
|
.execute(&mut conn);
|
|
}
|
|
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(SET_COOKIE, build_clear_session_cookie(&state));
|
|
Ok((headers, StatusCode::NO_CONTENT))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/auth/me",
|
|
responses((status = 200, description = "Authenticated principal", body = AuthenticatedUser)),
|
|
tag = "Auth"
|
|
)]
|
|
pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
|
|
Json(user)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/auth/tenants",
|
|
responses((status = 200, description = "Available tenants", body = TenantListResponse)),
|
|
tag = "Auth"
|
|
)]
|
|
pub async fn list_tenants(
|
|
State(state): State<AppState>,
|
|
auth: Option<TypedHeader<Authorization<Bearer>>>,
|
|
) -> AppResult<Json<TenantListResponse>> {
|
|
let bearer = auth.ok_or_else(AppError::unauthorized)?;
|
|
let token = bearer.token();
|
|
|
|
let user_id = match state.jwt.verify_token(token) {
|
|
Ok(claims) => claims.sub,
|
|
Err(_) => {
|
|
let claims = state
|
|
.jwt
|
|
.verify_tenant_selector_token(token)
|
|
.map_err(|_| AppError::unauthorized())?;
|
|
claims.sub
|
|
}
|
|
};
|
|
|
|
let mut conn = state.db_unscoped()?;
|
|
apply_user_guc(&mut conn, user_id)?;
|
|
|
|
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
|
|
.filter(memberships_dsl::user_id.eq(user_id))
|
|
.select(memberships_dsl::tenant_id)
|
|
.load(&mut conn)?;
|
|
|
|
clear_user_guc(&mut conn)?;
|
|
drop(conn);
|
|
|
|
let mut tenants = Vec::with_capacity(tenant_ids.len());
|
|
for tenant_id in tenant_ids {
|
|
let mut tenant_conn = state.db_for_tenant(tenant_id)?;
|
|
let name: String = tenant_dsl::tenants
|
|
.find(tenant_id)
|
|
.select(tenant_dsl::name)
|
|
.first(&mut tenant_conn)
|
|
.map_err(AppError::from)?;
|
|
tenants.push(TenantSnippet {
|
|
id: tenant_id,
|
|
name,
|
|
});
|
|
}
|
|
|
|
Ok(Json(TenantListResponse { tenants }))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/auth/passkeys/register/start",
|
|
responses((status = 200, description = "Passkey registration challenge", body = RegistrationChallengeResponse)),
|
|
tag = "Auth"
|
|
)]
|
|
pub async fn passkey_register_start(
|
|
State(state): State<AppState>,
|
|
user: AuthenticatedUser,
|
|
) -> AppResult<Json<RegistrationChallengeResponse>> {
|
|
let service = state
|
|
.passkeys
|
|
.as_ref()
|
|
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
|
|
|
let mut conn = state.db_unscoped()?;
|
|
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
|
|
let challenge = service.start_registration(&mut conn, ¤t_user)?;
|
|
Ok(Json(challenge))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/auth/passkeys/register/finish",
|
|
request_body = PasskeyRegistrationFinishPayload,
|
|
responses((status = 200, description = "Passkey registered", body = PasskeySummary)),
|
|
tag = "Auth"
|
|
)]
|
|
pub async fn passkey_register_finish(
|
|
State(state): State<AppState>,
|
|
user: AuthenticatedUser,
|
|
Json(payload): Json<PasskeyRegistrationFinishPayload>,
|
|
) -> AppResult<Json<PasskeySummary>> {
|
|
let service = state
|
|
.passkeys
|
|
.as_ref()
|
|
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
|
|
|
let mut conn = state.db_unscoped()?;
|
|
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
|
|
|
|
let PasskeyRegistrationFinishPayload {
|
|
challenge_id,
|
|
credential,
|
|
nickname,
|
|
} = payload;
|
|
|
|
let passkey = service.finish_registration(
|
|
&mut conn,
|
|
¤t_user,
|
|
challenge_id,
|
|
credential,
|
|
nickname,
|
|
)?;
|
|
|
|
Ok(Json(PasskeySummary::from(passkey)))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/auth/passkeys/login/start",
|
|
request_body = PasskeyLoginStartPayload,
|
|
responses((status = 200, description = "Passkey authentication challenge", body = AuthenticationChallengeResponse)),
|
|
tag = "Auth"
|
|
)]
|
|
pub async fn passkey_login_start(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<PasskeyLoginStartPayload>,
|
|
) -> AppResult<Json<AuthenticationChallengeResponse>> {
|
|
let service = state
|
|
.passkeys
|
|
.as_ref()
|
|
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
|
|
|
let username = payload.username.trim();
|
|
if username.is_empty() {
|
|
return Err(AppError::bad_request("username must not be empty"));
|
|
}
|
|
|
|
let mut conn = state.db_unscoped()?;
|
|
let user: User = dsl::users
|
|
.filter(dsl::username.eq(username))
|
|
.first(&mut conn)?;
|
|
|
|
let challenge = service.start_authentication(&mut conn, &user)?;
|
|
Ok(Json(challenge))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/auth/passkeys/login/finish",
|
|
request_body = PasskeyLoginFinishPayload,
|
|
responses(
|
|
(status = 200, description = "Passkey login successful", body = LoginResponseVariants),
|
|
(status = 401, description = "Authentication failed")
|
|
),
|
|
tag = "Auth"
|
|
)]
|
|
pub async fn passkey_login_finish(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<PasskeyLoginFinishPayload>,
|
|
) -> AppResult<Response> {
|
|
let service = state
|
|
.passkeys
|
|
.as_ref()
|
|
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
|
|
|
let mut conn = state.db_unscoped()?;
|
|
let (user, _passkey, auth_result) =
|
|
service.finish_authentication(&mut conn, payload.challenge_id, payload.credential)?;
|
|
|
|
if !auth_result.user_verified() {
|
|
return Err(AppError::unauthorized());
|
|
}
|
|
|
|
complete_login(&state, &mut conn, &user, None)
|
|
}
|
|
|
|
fn complete_login(
|
|
state: &AppState,
|
|
conn: &mut PgConnection,
|
|
user: &User,
|
|
preferred_tenant_id: Option<Uuid>,
|
|
) -> AppResult<Response> {
|
|
apply_user_guc(conn, user.id)?;
|
|
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
|
|
.filter(memberships_dsl::user_id.eq(user.id))
|
|
.select(memberships_dsl::tenant_id)
|
|
.load(conn)?;
|
|
clear_user_guc(conn)?;
|
|
|
|
tracing::debug!(user_id = %user.id, tenants = tenant_ids.len(), "passkey login memberships");
|
|
|
|
if tenant_ids.is_empty() {
|
|
return Err(AppError::unauthorized());
|
|
}
|
|
|
|
if let Some(preferred_id) = preferred_tenant_id {
|
|
if tenant_ids.iter().any(|id| *id == preferred_id) {
|
|
return issue_session(state, conn, user, preferred_id);
|
|
}
|
|
}
|
|
|
|
if tenant_ids.len() == 1 {
|
|
return issue_session(state, conn, user, tenant_ids[0]);
|
|
}
|
|
|
|
let selection_token = state
|
|
.jwt
|
|
.generate_tenant_selector_token(user.id)
|
|
.map_err(AppError::from)?;
|
|
|
|
let mut tenants = Vec::with_capacity(tenant_ids.len());
|
|
for tenant_id in tenant_ids {
|
|
let mut tenant_conn = state.db_for_tenant(tenant_id)?;
|
|
let name: String = tenant_dsl::tenants
|
|
.find(tenant_id)
|
|
.select(tenant_dsl::name)
|
|
.first(&mut tenant_conn)
|
|
.map_err(AppError::from)?;
|
|
tenants.push(TenantSnippet {
|
|
id: tenant_id,
|
|
name,
|
|
});
|
|
}
|
|
|
|
Ok(Json(TenantSelectionResponse {
|
|
access_token: selection_token,
|
|
tenants,
|
|
})
|
|
.into_response())
|
|
}
|
|
|
|
fn magic_token_login(
|
|
state: &AppState,
|
|
conn: &mut PgConnection,
|
|
token_value: &str,
|
|
username_hint: Option<&str>,
|
|
preferred_tenant_id: Option<Uuid>,
|
|
) -> AppResult<Response> {
|
|
if token_value.is_empty() {
|
|
return Err(AppError::bad_request("magic_token must not be empty"));
|
|
}
|
|
|
|
let token_hash = hash_magic_token(token_value);
|
|
let now = Utc::now();
|
|
let now_naive = now.naive_utc();
|
|
|
|
conn.transaction::<Response, AppError, _>(|conn| {
|
|
let magic = magic_dsl::magic_tokens
|
|
.filter(magic_dsl::token_hash.eq(&token_hash))
|
|
.filter(magic_dsl::expires_at.gt(now_naive))
|
|
.first::<MagicToken>(conn)
|
|
.map_err(|err| match err {
|
|
diesel::result::Error::NotFound => AppError::unauthorized(),
|
|
_ => AppError::from(err),
|
|
})?;
|
|
|
|
if let Some(limit) = magic.max_uses {
|
|
if magic.used_count >= limit {
|
|
return Err(AppError::unauthorized());
|
|
}
|
|
}
|
|
|
|
match magic.kind {
|
|
MagicTokenKind::EmailLogin | MagicTokenKind::DemoLogin => {}
|
|
}
|
|
|
|
let user: User = dsl::users
|
|
.find(magic.user_id)
|
|
.first(conn)
|
|
.map_err(AppError::from)?;
|
|
|
|
if let Some(expected) = username_hint {
|
|
if expected != user.username {
|
|
return Err(AppError::unauthorized());
|
|
}
|
|
}
|
|
|
|
diesel::update(magic_dsl::magic_tokens.filter(magic_dsl::id.eq(magic.id)))
|
|
.set((
|
|
magic_dsl::used_count.eq(magic.used_count + 1),
|
|
magic_dsl::last_used_at.eq(Some(now_naive)),
|
|
))
|
|
.execute(conn)?;
|
|
|
|
complete_login(state, conn, &user, preferred_tenant_id)
|
|
})
|
|
}
|
|
|
|
fn issue_session(
|
|
state: &AppState,
|
|
conn: &mut PgConnection,
|
|
user: &User,
|
|
tenant_id: Uuid,
|
|
) -> AppResult<Response> {
|
|
apply_tenant_guc(conn, tenant_id)?;
|
|
clear_user_guc(conn)?;
|
|
clear_user_session_hash(conn)?;
|
|
|
|
let now = Utc::now();
|
|
let access_token = state
|
|
.jwt
|
|
.generate_token(user.id, tenant_id, &user.username)
|
|
.map_err(AppError::from)?;
|
|
|
|
let tenant_name: String = tenant_dsl::tenants
|
|
.find(tenant_id)
|
|
.select(tenant_dsl::name)
|
|
.first(conn)
|
|
.map_err(AppError::from)?;
|
|
|
|
let session_value = generate_session_token();
|
|
let session_hash = hash_session_token(&session_value);
|
|
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
|
|
|
let new_session = NewUserSession {
|
|
id: Uuid::new_v4(),
|
|
user_id: user.id,
|
|
token_hash: session_hash,
|
|
issued_at: now.naive_utc(),
|
|
expires_at: refresh_expires_at.naive_utc(),
|
|
tenant_id,
|
|
};
|
|
|
|
diesel::insert_into(user_sessions::table)
|
|
.values(&new_session)
|
|
.execute(conn)?;
|
|
|
|
let mut response = Json(LoginResponse {
|
|
access_token,
|
|
token_type: "Bearer".to_string(),
|
|
expires_in: state.config.jwt_expiry_minutes * 60,
|
|
tenant: TenantSnippet {
|
|
id: tenant_id,
|
|
name: tenant_name,
|
|
},
|
|
})
|
|
.into_response();
|
|
|
|
response.headers_mut().insert(
|
|
SET_COOKIE,
|
|
build_session_cookie(state, &session_value, refresh_expires_at),
|
|
);
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
fn hash_session_token(token: &str) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(token.as_bytes());
|
|
hex::encode(hasher.finalize())
|
|
}
|
|
|
|
fn hash_magic_token(token: &str) -> String {
|
|
hash_session_token(token)
|
|
}
|
|
|
|
fn generate_session_token() -> String {
|
|
let mut bytes = [0u8; 32];
|
|
OsRng.fill_bytes(&mut bytes);
|
|
hex::encode(bytes)
|
|
}
|
|
|
|
fn build_session_cookie(
|
|
state: &AppState,
|
|
token: &str,
|
|
expires_at: chrono::DateTime<Utc>,
|
|
) -> HeaderValue {
|
|
let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds();
|
|
|
|
let mut parts = vec![format!("{}={}", SESSION_COOKIE_NAME, token)];
|
|
parts.push("Path=/".into());
|
|
parts.push("HttpOnly".into());
|
|
parts.push("SameSite=Strict".into());
|
|
parts.push(format!("Max-Age={}", max_age));
|
|
parts.push(format!("Expires={}", expires_at.to_rfc2822()));
|
|
if state.config.refresh_cookie_secure {
|
|
parts.push("Secure".into());
|
|
}
|
|
if let Some(domain) = &state.config.refresh_cookie_domain {
|
|
parts.push(format!("Domain={}", domain));
|
|
}
|
|
|
|
HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie")
|
|
}
|
|
|
|
fn build_clear_session_cookie(state: &AppState) -> HeaderValue {
|
|
let mut parts = vec![format!("{}=", SESSION_COOKIE_NAME)];
|
|
parts.push("Path=/".into());
|
|
parts.push("HttpOnly".into());
|
|
parts.push("SameSite=Strict".into());
|
|
parts.push("Max-Age=0".into());
|
|
parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT".into());
|
|
if state.config.refresh_cookie_secure {
|
|
parts.push("Secure".into());
|
|
}
|
|
if let Some(domain) = &state.config.refresh_cookie_domain {
|
|
parts.push(format!("Domain={}", domain));
|
|
}
|
|
|
|
HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie")
|
|
}
|