first commit
This commit is contained in:
32
auth-api/Cargo.toml
Normal file
32
auth-api/Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "auth-api"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "auth-api"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
shared = { path = "../shared" }
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
axum-extra = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
validator = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
tower = { workspace = true }
|
||||
sha2 = "0.10"
|
||||
time = { version = "0.3", features = ["serde-human-readable"] }
|
||||
15
auth-api/Dockerfile
Normal file
15
auth-api/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM rust:1.94-slim as builder
|
||||
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/ ./crates/
|
||||
COPY db/ ./db/
|
||||
COPY .sqlx/ ./.sqlx/
|
||||
ENV SQLX_OFFLINE=true
|
||||
RUN cargo build --release --bin auth-api
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /app/target/release/auth-api /usr/local/bin/auth-api
|
||||
EXPOSE 3001
|
||||
CMD ["auth-api"]
|
||||
64
auth-api/src/main.rs
Normal file
64
auth-api/src/main.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
mod routes;
|
||||
mod tokens;
|
||||
|
||||
use axum::{routing::post, Router};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use std::sync::Arc;
|
||||
use tokens::TokenService;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: sqlx::PgPool,
|
||||
pub tokens: Arc<TokenService>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "auth_api=debug,tower_http=debug".into()),
|
||||
))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||
|
||||
let db = PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&database_url)
|
||||
.await?;
|
||||
|
||||
sqlx::migrate!("../../db/migrations")
|
||||
.run(&db)
|
||||
.await?;
|
||||
|
||||
let state = AppState {
|
||||
db,
|
||||
tokens: Arc::new(TokenService::new(jwt_secret)),
|
||||
};
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_headers(Any)
|
||||
.allow_methods(Any);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/register", post(routes::register::register))
|
||||
.route("/login", post(routes::login::login))
|
||||
.route("/refresh", post(routes::refresh::refresh))
|
||||
.route("/logout", post(routes::logout::logout))
|
||||
.layer(cors)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
let addr = "0.0.0.0:3001";
|
||||
tracing::info!("auth-api listening on {addr}");
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
93
auth-api/src/routes/login.rs
Normal file
93
auth-api/src/routes/login.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||
use axum::{extract::State, http::header, response::IntoResponse, Json};
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::{errors::AppError, models::UserRole};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub handle: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
pub user_id: String,
|
||||
pub handle: String,
|
||||
pub role: UserRole,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT id, handle, role as "role: UserRole", password_hash FROM users WHERE handle = $1"#,
|
||||
req.handle
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Unauthorized("Invalid credentials".to_string()))?;
|
||||
|
||||
let parsed_hash = PasswordHash::new(&row.password_hash)
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
Argon2::default()
|
||||
.verify_password(req.password.as_bytes(), &parsed_hash)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid credentials".to_string()))?;
|
||||
|
||||
let access_token = state
|
||||
.tokens
|
||||
.generate_access_token(row.id, &row.handle, row.role.clone())
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
|
||||
let refresh_raw = crate::tokens::TokenService::generate_refresh_token();
|
||||
let refresh_hash = sha256_hex(&refresh_raw);
|
||||
let expires_at = Utc::now() + chrono::Duration::days(14);
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)",
|
||||
Uuid::new_v4(),
|
||||
row.id,
|
||||
refresh_hash,
|
||||
expires_at
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
let cookie = Cookie::build(("refresh_token", refresh_raw))
|
||||
.http_only(true)
|
||||
.secure(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.path("/auth")
|
||||
.max_age(time::Duration::days(14))
|
||||
.build();
|
||||
|
||||
let resp = LoginResponse {
|
||||
access_token,
|
||||
user_id: row.id.to_string(),
|
||||
handle: row.handle,
|
||||
role: row.role,
|
||||
};
|
||||
|
||||
Ok((
|
||||
[(header::SET_COOKIE, cookie.to_string())],
|
||||
Json(resp),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn sha256_hex(input: &str) -> String {
|
||||
use sha2::Digest;
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(input.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
result.iter().fold(String::new(), |mut acc, b| {
|
||||
use std::fmt::Write;
|
||||
write!(acc, "{:02x}", b).unwrap();
|
||||
acc
|
||||
})
|
||||
}
|
||||
28
auth-api/src/routes/logout.rs
Normal file
28
auth-api/src/routes/logout.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use axum::{extract::State, http::header, response::IntoResponse, Json};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar};
|
||||
use serde_json::json;
|
||||
use shared::errors::AppError;
|
||||
|
||||
use crate::{routes::login::sha256_hex, AppState};
|
||||
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
if let Some(cookie) = jar.get("refresh_token") {
|
||||
let hash = sha256_hex(cookie.value());
|
||||
sqlx::query!("UPDATE refresh_tokens SET revoked = true WHERE token_hash = $1", hash)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let removal = Cookie::build(("refresh_token", ""))
|
||||
.path("/auth")
|
||||
.max_age(time::Duration::seconds(0))
|
||||
.build();
|
||||
|
||||
Ok((
|
||||
[(header::SET_COOKIE, removal.to_string())],
|
||||
Json(json!({ "message": "logged out" })),
|
||||
))
|
||||
}
|
||||
4
auth-api/src/routes/mod.rs
Normal file
4
auth-api/src/routes/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod refresh;
|
||||
pub mod register;
|
||||
86
auth-api/src/routes/refresh.rs
Normal file
86
auth-api/src/routes/refresh.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use axum::{extract::State, http::header, response::IntoResponse, Json};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use chrono::Utc;
|
||||
use serde::Serialize;
|
||||
use shared::errors::AppError;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{routes::login::sha256_hex, AppState};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RefreshResponse {
|
||||
pub access_token: String,
|
||||
}
|
||||
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let raw_token = jar
|
||||
.get("refresh_token")
|
||||
.map(|c| c.value().to_owned())
|
||||
.ok_or_else(|| AppError::Unauthorized("No refresh token".to_string()))?;
|
||||
|
||||
let token_hash = sha256_hex(&raw_token);
|
||||
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT id, user_id, expires_at, revoked
|
||||
FROM refresh_tokens
|
||||
WHERE token_hash = $1"#,
|
||||
token_hash
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Unauthorized("Invalid refresh token".to_string()))?;
|
||||
|
||||
if row.revoked {
|
||||
return Err(AppError::Unauthorized("Token revoked".to_string()));
|
||||
}
|
||||
if row.expires_at < Utc::now() {
|
||||
return Err(AppError::Unauthorized("Token expired".to_string()));
|
||||
}
|
||||
|
||||
// Rotate: revoke old, issue new
|
||||
sqlx::query!("UPDATE refresh_tokens SET revoked = true WHERE id = $1", row.id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
let user = sqlx::query!(
|
||||
r#"SELECT id, handle, role as "role: shared::models::UserRole" FROM users WHERE id = $1"#,
|
||||
row.user_id
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
let access_token = state
|
||||
.tokens
|
||||
.generate_access_token(user.id, &user.handle, user.role.clone())
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
|
||||
let new_refresh_raw = crate::tokens::TokenService::generate_refresh_token();
|
||||
let new_refresh_hash = sha256_hex(&new_refresh_raw);
|
||||
let expires_at = Utc::now() + chrono::Duration::days(14);
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)",
|
||||
Uuid::new_v4(),
|
||||
row.user_id,
|
||||
new_refresh_hash,
|
||||
expires_at
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
let cookie = Cookie::build(("refresh_token", new_refresh_raw))
|
||||
.http_only(true)
|
||||
.secure(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.path("/auth")
|
||||
.max_age(time::Duration::days(14))
|
||||
.build();
|
||||
|
||||
Ok((
|
||||
[(header::SET_COOKIE, cookie.to_string())],
|
||||
Json(RefreshResponse { access_token }),
|
||||
))
|
||||
}
|
||||
62
auth-api/src/routes/register.rs
Normal file
62
auth-api/src/routes/register.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
|
||||
use axum::{extract::State, Json};
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::{errors::AppError, models::UserRole};
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct RegisterRequest {
|
||||
#[validate(length(min = 3, max = 30, message = "Handle must be 3-30 chars"))]
|
||||
pub handle: String,
|
||||
#[validate(length(min = 8, message = "Password must be at least 8 chars"))]
|
||||
pub password: String,
|
||||
pub role: UserRole,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RegisterResponse {
|
||||
pub user_id: Uuid,
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<Json<RegisterResponse>, AppError> {
|
||||
req.validate()
|
||||
.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(req.password.as_bytes(), &salt)
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?
|
||||
.to_string();
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO users (id, handle, role, password_hash) VALUES ($1, $2, $3::user_role, $4)"#,
|
||||
user_id,
|
||||
req.handle,
|
||||
req.role as UserRole,
|
||||
hash
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let sqlx::Error::Database(ref db_err) = e {
|
||||
if db_err.constraint() == Some("users_handle_key") {
|
||||
return AppError::BadRequest("Handle already taken".to_string());
|
||||
}
|
||||
}
|
||||
AppError::Database(e)
|
||||
})?;
|
||||
|
||||
Ok(Json(RegisterResponse {
|
||||
user_id,
|
||||
handle: req.handle,
|
||||
}))
|
||||
}
|
||||
59
auth-api/src/tokens.rs
Normal file
59
auth-api/src/tokens.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use chrono::Utc;
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use rand::Rng;
|
||||
use shared::models::{Claims, UserRole};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct TokenService {
|
||||
pub secret: String,
|
||||
pub access_expiry_secs: i64,
|
||||
pub refresh_expiry_secs: i64,
|
||||
}
|
||||
|
||||
impl TokenService {
|
||||
pub fn new(secret: String) -> Self {
|
||||
Self {
|
||||
secret,
|
||||
access_expiry_secs: 30 * 60, // 30 min
|
||||
refresh_expiry_secs: 14 * 24 * 3600, // 14 days
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_access_token(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
handle: &str,
|
||||
role: UserRole,
|
||||
) -> anyhow::Result<String> {
|
||||
let now = Utc::now().timestamp() as usize;
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
handle: handle.to_string(),
|
||||
role,
|
||||
iat: now,
|
||||
exp: now + self.access_expiry_secs as usize,
|
||||
};
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(self.secret.as_bytes()),
|
||||
)?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub fn validate_access_token(&self, token: &str) -> anyhow::Result<Claims> {
|
||||
let data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(self.secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)?;
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
pub fn generate_refresh_token() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..64)
|
||||
.map(|_| rng.sample(rand::distributions::Alphanumeric) as char)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user