add auth system: users, login, JWT, protected routes
Domain: User entity, AuthPort/PasswordHashPort/SecretStore ports. Adapters: auth (argon2 hashing, JWT tokens), secret-store (env-based), config-sqlite user repository, http-api auth routes + extractors. Application: auth_service. SPA: login page, auth client, protected router.
This commit is contained in:
90
crates/adapters/auth/src/lib.rs
Normal file
90
crates/adapters/auth/src/lib.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use argon2::{
|
||||
Argon2,
|
||||
password_hash::{PasswordHasher, PasswordVerifier, SaltString},
|
||||
};
|
||||
use domain::{AuthPort, PasswordHashPort, UserId};
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use rand_core::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub struct AuthConfig {
|
||||
pub secret: String,
|
||||
pub ttl_seconds: u64,
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
pub fn from_env() -> Result<Self, String> {
|
||||
let secret = std::env::var("JWT_SECRET")
|
||||
.map_err(|_| "JWT_SECRET env var is required".to_string())?;
|
||||
if secret.is_empty() {
|
||||
return Err("JWT_SECRET must not be empty".into());
|
||||
}
|
||||
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3600u64);
|
||||
Ok(Self {
|
||||
secret,
|
||||
ttl_seconds,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Claims {
|
||||
sub: u32,
|
||||
exp: u64,
|
||||
}
|
||||
|
||||
pub struct JwtAuthService {
|
||||
config: AuthConfig,
|
||||
}
|
||||
|
||||
impl JwtAuthService {
|
||||
pub fn new(config: AuthConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthPort for JwtAuthService {
|
||||
fn generate_token(&self, user_id: UserId) -> String {
|
||||
let exp = jsonwebtoken::get_current_timestamp() + self.config.ttl_seconds;
|
||||
let claims = Claims { sub: user_id, exp };
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(self.config.secret.as_bytes()),
|
||||
)
|
||||
.expect("JWT encoding should not fail")
|
||||
}
|
||||
|
||||
fn validate_token(&self, token: &str) -> Option<UserId> {
|
||||
let data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(self.config.secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.ok()?;
|
||||
Some(data.claims.sub)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Argon2Hasher;
|
||||
|
||||
impl PasswordHashPort for Argon2Hasher {
|
||||
async fn hash(&self, plain: &str) -> Result<String, String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(plain.as_bytes(), &salt)
|
||||
.map_err(|e| e.to_string())?
|
||||
.to_string();
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
async fn verify(&self, plain: &str, hash: &str) -> Result<bool, String> {
|
||||
let parsed = argon2::password_hash::PasswordHash::new(hash).map_err(|e| e.to_string())?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(plain.as_bytes(), &parsed)
|
||||
.is_ok())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user