feat: Implement full auth pipeline — login, register, JWT, bootstrap

Backend auth flow is now functional:
- services/auth.rs: Argon2id password hashing, JWT access/refresh tokens
- services/encryption.rs: AES-256-GCM encrypt/decrypt, hex token generation
- api/auth.rs: Login, register, refresh, logout, /me endpoints
- middleware/auth.rs: JWT Bearer token extractor (FromRequestParts)
- db/users.rs + licenses.rs: Full CRUD with runtime queries (no compile-time DB)
- main.rs: Bootstrap admin user on first run via ADMIN_EMAIL/ADMIN_PASSWORD env vars
- NATS connection now optional for dev (graceful fallback)
- Added hex and http crates to Cargo.toml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-14 21:49:37 -05:00
parent 9217f77998
commit 5668675b6a
10 changed files with 671 additions and 101 deletions

View File

@@ -1,11 +1,16 @@
use std::sync::Arc;
use axum::{
routing::post,
Router,
extract::State,
routing::{get, post},
Json, Router,
};
use crate::models::error::ApiResult;
use crate::db;
use crate::middleware::auth::AuthUser;
use crate::models::auth::{AuthResponse, LoginRequest, RegisterRequest, UserPublic};
use crate::models::error::{ApiError, ApiResult};
use crate::services::auth as auth_service;
use crate::AppState;
pub fn router() -> Router<Arc<AppState>> {
@@ -17,32 +22,237 @@ pub fn router() -> Router<Arc<AppState>> {
.route("/setup-totp", post(setup_totp))
.route("/backup-codes", post(backup_codes))
.route("/logout", post(logout))
.route("/me", get(me))
}
async fn login() -> ApiResult<impl axum::response::IntoResponse> {
todo!()
async fn login(
State(state): State<Arc<AppState>>,
Json(body): Json<LoginRequest>,
) -> ApiResult<Json<AuthResponse>> {
// Look up user by email
let user = db::users::get_user_by_email(&state.db, &body.email)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::BadRequest("Invalid email or password".to_string()))?;
// Verify password
let valid = auth_service::verify_password(&body.password, &user.password_hash)
.map_err(|e| ApiError::Internal(e.to_string()))?;
if !valid {
return Err(ApiError::BadRequest("Invalid email or password".to_string()));
}
// Check if TOTP is enabled (for now, skip TOTP challenge)
let requires_totp = user.totp_enabled;
// Look up license for the user
let license = db::licenses::get_license_by_user(&state.db, user.id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let license_id = license.as_ref().map(|l| l.id);
let role = None; // TODO: look up role from team_members
// Create tokens
let access_token = auth_service::create_access_token(
&state.config,
user.id,
&user.email,
license_id,
role,
)
.map_err(|e| ApiError::Internal(e.to_string()))?;
let refresh_token = auth_service::create_refresh_token(
&state.config,
user.id,
&user.email,
)
.map_err(|e| ApiError::Internal(e.to_string()))?;
// Update last login timestamp
let _ = db::users::update_last_login(&state.db, user.id).await;
Ok(Json(AuthResponse {
access_token,
refresh_token,
requires_totp,
user: UserPublic {
id: user.id,
email: user.email,
username: user.username,
totp_enabled: user.totp_enabled,
email_verified: user.email_verified,
},
}))
}
async fn register() -> ApiResult<impl axum::response::IntoResponse> {
todo!()
async fn register(
State(state): State<Arc<AppState>>,
Json(body): Json<RegisterRequest>,
) -> ApiResult<Json<AuthResponse>> {
// Validate input
if body.email.is_empty() || !body.email.contains('@') {
return Err(ApiError::BadRequest("Invalid email address".to_string()));
}
if body.username.len() < 3 {
return Err(ApiError::BadRequest("Username must be at least 3 characters".to_string()));
}
if body.password.len() < 8 {
return Err(ApiError::BadRequest("Password must be at least 8 characters".to_string()));
}
// Verify the license key exists and is unclaimed
let license = db::licenses::get_license_by_key(&state.db, &body.license_key)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::BadRequest("Invalid license key".to_string()))?;
if license.status != "active" {
return Err(ApiError::BadRequest("License is not active".to_string()));
}
// Hash password
let password_hash = auth_service::hash_password(&body.password)
.map_err(|e| ApiError::Internal(e.to_string()))?;
// Create user
let user_id = db::users::create_user(&state.db, &body.email, &body.username, &password_hash)
.await
.map_err(|e| {
if e.to_string().contains("duplicate key") || e.to_string().contains("unique") {
ApiError::Conflict("Email or username already exists".to_string())
} else {
ApiError::Internal(e.to_string())
}
})?;
// Create tokens
let access_token = auth_service::create_access_token(
&state.config,
user_id,
&body.email,
Some(license.id),
Some("Owner".to_string()),
)
.map_err(|e| ApiError::Internal(e.to_string()))?;
let refresh_token = auth_service::create_refresh_token(
&state.config,
user_id,
&body.email,
)
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(AuthResponse {
access_token,
refresh_token,
requires_totp: false,
user: UserPublic {
id: user_id,
email: body.email,
username: body.username,
totp_enabled: false,
email_verified: false,
},
}))
}
async fn verify_totp() -> ApiResult<impl axum::response::IntoResponse> {
todo!()
#[derive(serde::Deserialize)]
struct RefreshRequest {
refresh_token: String,
}
async fn refresh() -> ApiResult<impl axum::response::IntoResponse> {
todo!()
async fn refresh(
State(state): State<Arc<AppState>>,
Json(body): Json<RefreshRequest>,
) -> ApiResult<Json<serde_json::Value>> {
// Validate refresh token
let claims = auth_service::validate_token(&state.config, &body.refresh_token)
.map_err(|_| ApiError::Unauthorized)?;
if claims.token_type != "refresh" {
return Err(ApiError::Unauthorized);
}
// Look up user to ensure they still exist
let user = db::users::get_user_by_id(&state.db, claims.sub)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::Unauthorized)?;
// Get license
let license = db::licenses::get_license_by_user(&state.db, user.id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let license_id = license.as_ref().map(|l| l.id);
// Issue new access token
let access_token = auth_service::create_access_token(
&state.config,
user.id,
&user.email,
license_id,
None,
)
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(serde_json::json!({
"access_token": access_token,
})))
}
async fn setup_totp() -> ApiResult<impl axum::response::IntoResponse> {
todo!()
async fn verify_totp() -> ApiResult<Json<serde_json::Value>> {
// TODO: Implement TOTP verification
Err(ApiError::BadRequest("TOTP not yet implemented".to_string()))
}
async fn backup_codes() -> ApiResult<impl axum::response::IntoResponse> {
todo!()
async fn setup_totp() -> ApiResult<Json<serde_json::Value>> {
// TODO: Implement TOTP setup
Err(ApiError::BadRequest("TOTP setup not yet implemented".to_string()))
}
async fn logout() -> ApiResult<impl axum::response::IntoResponse> {
todo!()
async fn backup_codes() -> ApiResult<Json<serde_json::Value>> {
// TODO: Implement backup code generation
Err(ApiError::BadRequest("Backup codes not yet implemented".to_string()))
}
/// GET /api/auth/me — returns the authenticated user's profile + license
async fn me(
auth: AuthUser,
State(state): State<Arc<AppState>>,
) -> ApiResult<Json<serde_json::Value>> {
let user = db::users::get_user_by_id(&state.db, auth.user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::Unauthorized)?;
let license = if let Some(lid) = auth.license_id {
db::licenses::get_license_by_id(&state.db, lid)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
} else {
db::licenses::get_license_by_user(&state.db, user.id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
};
Ok(Json(serde_json::json!({
"user": UserPublic {
id: user.id,
email: user.email,
username: user.username,
totp_enabled: user.totp_enabled,
email_verified: user.email_verified,
},
"license": license,
})))
}
async fn logout() -> ApiResult<Json<serde_json::Value>> {
// Stateless JWT — logout is handled client-side by discarding tokens.
// TODO: Implement token blacklisting if needed.
Ok(Json(serde_json::json!({ "message": "Logged out" })))
}