feat(server): validate username length/chars and minimum password length on register
Username: 3–32 chars, alphanumeric + underscore only. Password: minimum 8 characters. Both return HTTP 400 Bad Request with a human-readable message. Adds three integration tests for the new validation rules. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -95,20 +95,47 @@ pub fn make_refresh_token(user_id: &str, secret: &str) -> Result<String, AppErro
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// `POST /api/auth/register` — create a new account and return tokens.
|
/// `POST /api/auth/register` — create a new account and return tokens.
|
||||||
|
/// Minimum and maximum allowed username lengths.
|
||||||
|
const USERNAME_MIN: usize = 3;
|
||||||
|
const USERNAME_MAX: usize = 32;
|
||||||
|
/// Minimum password length.
|
||||||
|
const PASSWORD_MIN: usize = 8;
|
||||||
|
|
||||||
|
/// Returns `true` if every character in `s` is ASCII alphanumeric or `_`.
|
||||||
|
fn username_chars_ok(s: &str) -> bool {
|
||||||
|
s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
State(pool): State<SqlitePool>,
|
State(pool): State<SqlitePool>,
|
||||||
Json(body): Json<AuthRequest>,
|
Json(body): Json<AuthRequest>,
|
||||||
) -> Result<Json<AuthResponse>, AppError> {
|
) -> Result<Json<AuthResponse>, AppError> {
|
||||||
// Validate input minimally.
|
// Validate username: 3–32 characters, alphanumeric + underscores only.
|
||||||
if body.username.trim().is_empty() || body.password.is_empty() {
|
let trimmed = body.username.trim();
|
||||||
return Err(AppError::BadRequest("username and password are required".into()));
|
if trimmed.len() < USERNAME_MIN || trimmed.len() > USERNAME_MAX {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"username must be {USERNAME_MIN}–{USERNAME_MAX} characters"
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
if !username_chars_ok(trimmed) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"username may only contain letters, digits, and underscores".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Validate password: minimum 8 characters.
|
||||||
|
if body.password.len() < PASSWORD_MIN {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"password must be at least {PASSWORD_MIN} characters"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let username = trimmed.to_string();
|
||||||
|
|
||||||
// Check for duplicate username. SQLite returns TEXT as nullable so we
|
// Check for duplicate username. SQLite returns TEXT as nullable so we
|
||||||
// flatten the Option<Option<String>> produced by fetch_optional.
|
// flatten the Option<Option<String>> produced by fetch_optional.
|
||||||
let existing: Option<String> = sqlx::query_scalar!(
|
let existing: Option<String> = sqlx::query_scalar!(
|
||||||
"SELECT id FROM users WHERE username = ?",
|
"SELECT id FROM users WHERE username = ?",
|
||||||
body.username
|
username
|
||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await?
|
.await?
|
||||||
@@ -125,7 +152,7 @@ pub async fn register(
|
|||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||||
user_id,
|
user_id,
|
||||||
body.username,
|
username,
|
||||||
password_hash,
|
password_hash,
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ async fn register_creates_account_and_returns_tokens() {
|
|||||||
let resp = post_json(
|
let resp = post_json(
|
||||||
app,
|
app,
|
||||||
"/api/auth/register",
|
"/api/auth/register",
|
||||||
serde_json::json!({ "username": "alice", "password": "hunter2" }),
|
serde_json::json!({ "username": "alice", "password": "hunter2!" }),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ async fn register_creates_account_and_returns_tokens() {
|
|||||||
async fn register_duplicate_username_returns_conflict() {
|
async fn register_duplicate_username_returns_conflict() {
|
||||||
set_jwt_secret();
|
set_jwt_secret();
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let creds = serde_json::json!({ "username": "bob", "password": "secret" });
|
let creds = serde_json::json!({ "username": "bob", "password": "s3cr3t!!" });
|
||||||
|
|
||||||
// First registration succeeds.
|
// First registration succeeds.
|
||||||
let first = post_json(app.clone(), "/api/auth/register", creds.clone()).await;
|
let first = post_json(app.clone(), "/api/auth/register", creds.clone()).await;
|
||||||
@@ -244,6 +244,48 @@ async fn register_duplicate_username_returns_conflict() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Short username (< 3 chars) is rejected with 400.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_rejects_short_username() {
|
||||||
|
set_jwt_secret();
|
||||||
|
let app = build_test_router(test_pool().await);
|
||||||
|
let resp = post_json(
|
||||||
|
app,
|
||||||
|
"/api/auth/register",
|
||||||
|
serde_json::json!({ "username": "ab", "password": "validpassword" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Username with disallowed characters is rejected with 400.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_rejects_invalid_username_chars() {
|
||||||
|
set_jwt_secret();
|
||||||
|
let app = build_test_router(test_pool().await);
|
||||||
|
let resp = post_json(
|
||||||
|
app,
|
||||||
|
"/api/auth/register",
|
||||||
|
serde_json::json!({ "username": "bad name!", "password": "validpassword" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Password shorter than 8 characters is rejected with 400.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_rejects_short_password() {
|
||||||
|
set_jwt_secret();
|
||||||
|
let app = build_test_router(test_pool().await);
|
||||||
|
let resp = post_json(
|
||||||
|
app,
|
||||||
|
"/api/auth/register",
|
||||||
|
serde_json::json!({ "username": "validuser", "password": "short" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
/// `POST /api/auth/login` with correct credentials returns 200 with both tokens.
|
/// `POST /api/auth/login` with correct credentials returns 200 with both tokens.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_with_correct_credentials_returns_tokens() {
|
async fn login_with_correct_credentials_returns_tokens() {
|
||||||
@@ -433,7 +475,7 @@ async fn pull_before_push_returns_default_payload() {
|
|||||||
set_jwt_secret();
|
set_jwt_secret();
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (access, _) = register_user(app.clone(), "ivan", "nopush").await;
|
let (access, _) = register_user(app.clone(), "ivan", "nopush!!").await;
|
||||||
|
|
||||||
let resp = get_authed(app, "/api/sync/pull", &access).await;
|
let resp = get_authed(app, "/api/sync/pull", &access).await;
|
||||||
assert_eq!(resp.status(), StatusCode::OK, "pull with no data must return 200");
|
assert_eq!(resp.status(), StatusCode::OK, "pull with no data must return 200");
|
||||||
|
|||||||
Reference in New Issue
Block a user