112
crates/forage-core/src/auth/mod.rs
Normal file
112
crates/forage-core/src/auth/mod.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
mod validation;
|
||||
|
||||
pub use validation::{validate_email, validate_password, validate_username};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Tokens returned by forest-server after login/register.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthTokens {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub expires_in_seconds: i64,
|
||||
}
|
||||
|
||||
/// Minimal user info from forest-server.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub emails: Vec<UserEmail>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserEmail {
|
||||
pub email: String,
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
/// A personal access token (metadata only, no raw key).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersonalAccessToken {
|
||||
pub token_id: String,
|
||||
pub name: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub last_used: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of creating a PAT - includes the raw key shown once.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreatedToken {
|
||||
pub token: PersonalAccessToken,
|
||||
pub raw_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
#[error("invalid credentials")]
|
||||
InvalidCredentials,
|
||||
|
||||
#[error("already exists: {0}")]
|
||||
AlreadyExists(String),
|
||||
|
||||
#[error("not authenticated")]
|
||||
NotAuthenticated,
|
||||
|
||||
#[error("token expired")]
|
||||
TokenExpired,
|
||||
|
||||
#[error("forest-server unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Trait for communicating with forest-server's UsersService.
|
||||
/// Object-safe via async_trait so we can use `Arc<dyn ForestAuth>`.
|
||||
#[async_trait::async_trait]
|
||||
pub trait ForestAuth: Send + Sync {
|
||||
async fn register(
|
||||
&self,
|
||||
username: &str,
|
||||
email: &str,
|
||||
password: &str,
|
||||
) -> Result<AuthTokens, AuthError>;
|
||||
|
||||
async fn login(
|
||||
&self,
|
||||
identifier: &str,
|
||||
password: &str,
|
||||
) -> Result<AuthTokens, AuthError>;
|
||||
|
||||
async fn refresh_token(
|
||||
&self,
|
||||
refresh_token: &str,
|
||||
) -> Result<AuthTokens, AuthError>;
|
||||
|
||||
async fn logout(&self, refresh_token: &str) -> Result<(), AuthError>;
|
||||
|
||||
async fn get_user(&self, access_token: &str) -> Result<User, AuthError>;
|
||||
|
||||
async fn list_tokens(
|
||||
&self,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<PersonalAccessToken>, AuthError>;
|
||||
|
||||
async fn create_token(
|
||||
&self,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
) -> Result<CreatedToken, AuthError>;
|
||||
|
||||
async fn delete_token(
|
||||
&self,
|
||||
access_token: &str,
|
||||
token_id: &str,
|
||||
) -> Result<(), AuthError>;
|
||||
}
|
||||
120
crates/forage-core/src/auth/validation.rs
Normal file
120
crates/forage-core/src/auth/validation.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct ValidationError(pub String);
|
||||
|
||||
pub fn validate_email(email: &str) -> Result<(), ValidationError> {
|
||||
if email.is_empty() {
|
||||
return Err(ValidationError("Email is required".into()));
|
||||
}
|
||||
if !email.contains('@') || !email.contains('.') {
|
||||
return Err(ValidationError("Invalid email format".into()));
|
||||
}
|
||||
if email.len() > 254 {
|
||||
return Err(ValidationError("Email too long".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_password(password: &str) -> Result<(), ValidationError> {
|
||||
if password.is_empty() {
|
||||
return Err(ValidationError("Password is required".into()));
|
||||
}
|
||||
if password.len() < 12 {
|
||||
return Err(ValidationError(
|
||||
"Password must be at least 12 characters".into(),
|
||||
));
|
||||
}
|
||||
if password.len() > 1024 {
|
||||
return Err(ValidationError("Password too long".into()));
|
||||
}
|
||||
if !password.chars().any(|c| c.is_uppercase()) {
|
||||
return Err(ValidationError(
|
||||
"Password must contain at least one uppercase letter".into(),
|
||||
));
|
||||
}
|
||||
if !password.chars().any(|c| c.is_lowercase()) {
|
||||
return Err(ValidationError(
|
||||
"Password must contain at least one lowercase letter".into(),
|
||||
));
|
||||
}
|
||||
if !password.chars().any(|c| c.is_ascii_digit()) {
|
||||
return Err(ValidationError(
|
||||
"Password must contain at least one digit".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_username(username: &str) -> Result<(), ValidationError> {
|
||||
if username.is_empty() {
|
||||
return Err(ValidationError("Username is required".into()));
|
||||
}
|
||||
if username.len() < 3 {
|
||||
return Err(ValidationError(
|
||||
"Username must be at least 3 characters".into(),
|
||||
));
|
||||
}
|
||||
if username.len() > 64 {
|
||||
return Err(ValidationError("Username too long".into()));
|
||||
}
|
||||
if !username
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Err(ValidationError(
|
||||
"Username can only contain letters, numbers, hyphens, and underscores".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn valid_email() {
|
||||
assert!(validate_email("user@example.com").is_ok());
|
||||
assert!(validate_email("a@b.c").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_email() {
|
||||
assert!(validate_email("").is_err());
|
||||
assert!(validate_email("noat").is_err());
|
||||
assert!(validate_email("no@dot").is_err());
|
||||
assert!(validate_email(&format!("{}@b.c", "a".repeat(251))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_password() {
|
||||
assert!(validate_password("SecurePass123").is_ok());
|
||||
assert!(validate_password("MyLongPassphrase1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_password() {
|
||||
assert!(validate_password("").is_err());
|
||||
assert!(validate_password("short").is_err());
|
||||
assert!(validate_password("12345678901").is_err()); // 11 chars
|
||||
assert!(validate_password(&"a".repeat(1025)).is_err());
|
||||
assert!(validate_password("alllowercase1").is_err()); // no uppercase
|
||||
assert!(validate_password("ALLUPPERCASE1").is_err()); // no lowercase
|
||||
assert!(validate_password("NoDigitsHere!").is_err()); // no digit
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_username() {
|
||||
assert!(validate_username("alice").is_ok());
|
||||
assert!(validate_username("bob-123").is_ok());
|
||||
assert!(validate_username("foo_bar").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_username() {
|
||||
assert!(validate_username("").is_err());
|
||||
assert!(validate_username("ab").is_err());
|
||||
assert!(validate_username("has spaces").is_err());
|
||||
assert!(validate_username("has@symbol").is_err());
|
||||
assert!(validate_username(&"a".repeat(65)).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user