feat: validation on form registration, thanks to @ValueZer0 for your

work on #299 resolves #191
This commit is contained in:
FrenchGithubUser
2025-11-25 22:22:09 +01:00
parent 168f444e7d
commit 8aa7cfca8f
8 changed files with 306 additions and 36 deletions

View File

@@ -1,4 +1,12 @@
use crate::{services::email_service::EmailService, Arcadia};
use crate::{
services::{
auth_service::{
validate_email, validate_password, validate_password_verification, validate_username,
},
email_service::EmailService,
},
Arcadia,
};
use actix_web::{web, HttpRequest, HttpResponse};
use arcadia_common::error::{Error, Result};
use arcadia_shared::tracker::models::user::APIInsertUser;
@@ -58,6 +66,11 @@ pub async fn exec<R: RedisPoolInterface + 'static>(
invitation = Invitation::default();
}
validate_email(&new_user.email)?;
validate_username(&new_user.username)?;
validate_password(&new_user.password)?;
validate_password_verification(&new_user.password, &new_user.password_verify)?;
let client_ip = req
.connection_info()
.realip_remote_addr()

View File

@@ -0,0 +1,55 @@
use std::sync::LazyLock;
use arcadia_common::error::{Error, Result};
use regex::Regex;
static EMAIL_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap());
static USERNAME_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_-]{4,15}$").unwrap());
pub fn validate_email(email: &str) -> Result<()> {
if !EMAIL_REGEX.is_match(email) {
return Err(Error::InvalidEmailAddress);
}
Ok(())
}
pub fn validate_username(username: &str) -> Result<()> {
if !USERNAME_REGEX.is_match(username) {
return Err(Error::InvalidUsername);
}
Ok(())
}
pub fn validate_password(password: &str) -> Result<()> {
if password.len() < 12 {
return Err(Error::BadRequest(
"Password must be at least 12 characters long".to_string(),
));
}
if !password.chars().any(|c| c.is_uppercase()) {
return Err(Error::BadRequest(
"Password must contain at least one uppercase letter".to_string(),
));
}
if !password.chars().any(|c| c.is_lowercase()) {
return Err(Error::BadRequest(
"Password must contain at least one lowercase letter".to_string(),
));
}
if !password.chars().any(|c| c.is_numeric()) {
return Err(Error::BadRequest(
"Password must contain at least one number".to_string(),
));
}
Ok(())
}
pub fn validate_password_verification(password: &str, password_verify: &str) -> Result<()> {
if password != password_verify {
return Err(Error::BadRequest("Passwords do not match".to_string()));
}
Ok(())
}

View File

@@ -1,5 +1,6 @@
pub mod announce_service;
pub mod auth;
pub mod auth_service;
pub mod common_service;
pub mod email_service;
pub mod external_db_service;

View File

@@ -55,8 +55,8 @@ async fn test_open_registration(pool: PgPool) {
.uri("/api/auth/register")
.set_json(RegisterRequest {
username: "test_user",
password: "test_password",
password_verify: "test_password",
password: "TestPassword123",
password_verify: "TestPassword123",
email: "test_email@testdomain.com",
})
.to_request();
@@ -100,8 +100,8 @@ async fn test_duplicate_username_registration(pool: PgPool) {
.uri("/api/auth/register")
.set_json(RegisterRequest {
username: "duplicate_user",
password: "test_password",
password_verify: "test_password",
password: "TestPassword123",
password_verify: "TestPassword123",
email: "test_email@testdomain.com",
})
.to_request();
@@ -115,8 +115,8 @@ async fn test_duplicate_username_registration(pool: PgPool) {
.uri("/api/auth/register")
.set_json(RegisterRequest {
username: "duplicate_user",
password: "different_password",
password_verify: "different_password",
password: "DifferentPassword456",
password_verify: "DifferentPassword456",
email: "different_email@testdomain.com",
})
.to_request();
@@ -153,8 +153,8 @@ async fn test_closed_registration_failures(pool: PgPool) {
.uri("/api/auth/register")
.set_json(RegisterRequest {
username: "test_user",
password: "test_password",
password_verify: "test_password",
password: "TestPassword123",
password_verify: "TestPassword123",
email: "test_email@testdomain.com",
})
.to_request();
@@ -174,8 +174,8 @@ async fn test_closed_registration_failures(pool: PgPool) {
.uri("/api/auth/register?invitation_key=invalid")
.set_json(RegisterRequest {
username: "test_user",
password: "test_password",
password_verify: "test_password",
password: "TestPassword123",
password_verify: "TestPassword123",
email: "test_email@testdomain.com",
})
.to_request();
@@ -210,8 +210,8 @@ async fn test_closed_registration_success(pool: PgPool) {
.uri("/api/auth/register?invitation_key=valid_key")
.set_json(RegisterRequest {
username: "test_user2",
password: "test_password2",
password_verify: "test_password2",
password: "TestPassword456",
password_verify: "TestPassword456",
email: "newuser@testdomain.com",
})
.to_request();
@@ -242,8 +242,8 @@ async fn test_closed_registration_success(pool: PgPool) {
.uri("/api/auth/register?invitation_key=valid_key")
.set_json(RegisterRequest {
username: "test_user3",
password: "test_password3",
password_verify: "test_password3",
password: "TestPassword789",
password_verify: "TestPassword789",
email: "newuser2@testdomain.com",
})
.to_request();

View File

@@ -0,0 +1,111 @@
use arcadia_api::services::auth_service::{
validate_email, validate_password, validate_password_verification, validate_username,
};
use arcadia_common::error::Error;
#[test]
fn test_validate_email() {
// Valid emails
assert!(validate_email("test@example.com").is_ok());
assert!(validate_email("user.name@domain.co.uk").is_ok());
assert!(validate_email("user+tag@example.org").is_ok());
// Invalid emails
assert!(matches!(
validate_email(""),
Err(Error::InvalidEmailAddress)
));
assert!(matches!(
validate_email(" "),
Err(Error::InvalidEmailAddress)
));
assert!(matches!(
validate_email("invalid-email"),
Err(Error::InvalidEmailAddress)
));
assert!(matches!(
validate_email("@example.com"),
Err(Error::InvalidEmailAddress)
));
assert!(matches!(
validate_email("user@"),
Err(Error::InvalidEmailAddress)
));
}
#[test]
fn test_validate_username() {
// Valid usernames
assert!(validate_username("user123").is_ok());
assert!(validate_username("test_user").is_ok());
assert!(validate_username("user-name").is_ok());
assert!(validate_username("user123name").is_ok());
assert!(validate_username("a".repeat(4).as_str()).is_ok());
assert!(validate_username("a".repeat(15).as_str()).is_ok());
// Invalid usernames
assert!(matches!(validate_username(""), Err(Error::InvalidUsername)));
assert!(matches!(
validate_username(" "),
Err(Error::InvalidUsername)
));
assert!(matches!(
validate_username("abc"),
Err(Error::InvalidUsername)
)); // too short
assert!(matches!(
validate_username("a".repeat(16).as_str()),
Err(Error::InvalidUsername)
)); // too long
assert!(matches!(
validate_username("user@name"),
Err(Error::InvalidUsername)
)); // invalid char
assert!(matches!(
validate_username("user name"),
Err(Error::InvalidUsername)
)); // space
}
#[test]
fn test_validate_password() {
// Valid passwords
assert!(validate_password("Password1234").is_ok());
assert!(validate_password("MySecurePass123").is_ok());
assert!(validate_password(&("a".repeat(12) + "A1")).is_ok());
// Invalid passwords
assert!(matches!(validate_password(""), Err(Error::BadRequest(_))));
assert!(matches!(
validate_password("short"),
Err(Error::BadRequest(_))
)); // too short
assert!(matches!(
validate_password("nouppercase123"),
Err(Error::BadRequest(_))
)); // no uppercase
assert!(matches!(
validate_password("NOLOWERCASE123"),
Err(Error::BadRequest(_))
)); // no lowercase
assert!(matches!(
validate_password("NoNumbers"),
Err(Error::BadRequest(_))
)); // no numbers
}
#[test]
fn test_validate_password_verification() {
// Valid password verification
assert!(validate_password_verification("Password1234", "Password1234").is_ok());
// Invalid password verification
assert!(matches!(
validate_password_verification("Password1234", ""),
Err(Error::BadRequest(_))
));
assert!(matches!(
validate_password_verification("Password1234", "DifferentPass123"),
Err(Error::BadRequest(_))
));
}

View File

@@ -264,6 +264,12 @@ pub enum Error {
#[error("error getting musicbrainz data")]
ErrorGettingMusicbrainzData(#[source] musicbrainz_rs::Error),
#[error("invalid email address")]
InvalidEmailAddress,
#[error("invalid username")]
InvalidUsername,
#[error("invalid musicbrainz url")]
InvalidMusicbrainzUrl,

View File

@@ -1,23 +1,37 @@
<template>
<div class="form">
<InputText class="form-item" name="email" type="text" :placeholder="t('user.email')" v-model="form.email" />
<!-- <Message v-if="$form.username?.invalid" severity="error" size="small" variant="simple">{{
$form.username.error?.message
}}</Message> -->
<Form
class="form"
v-slot="$form"
:initialValues="form"
:resolver="resolver"
@submit="handleRegister"
validateOnSubmit
:validateOnValueUpdate="false"
validateOnBlur
ref="formRef"
>
<InputText class="form-item" name="email" type="email" :placeholder="t('user.email')" v-model="form.email" />
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">
{{ $form.email.error?.message }}
</Message>
<InputText class="form-item" name="username" type="text" :placeholder="t('user.username')" v-model="form.username" />
<!-- <Message v-if="$form.username?.invalid" severity="error" size="small" variant="simple">{{
$form.username.error?.message
}}</Message> -->
<Password class="form-item" name="password" v-model="form.password" :placeholder="t('user.password')" :feedback="false" toggleMask />
<!-- <Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
$form.email.error?.message
}}</Message> -->
<Password class="form-item" name="password_verify" v-model="form.password_verify" :placeholder="t('user.password_verify')" :feedback="false" toggleMask />
<!-- <Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
$form.email.error?.message
}}</Message> -->
<Button class="form-item w-full" type="submit" severity="secondary" @click="handleRegister" :loading :label="t('user.register')" />
</div>
<Message v-if="$form.username?.invalid" severity="error" size="small" variant="simple">
{{ $form.username.error?.message }}
</Message>
<Password class="form-item" name="password" :placeholder="t('user.password')" v-model="form.password" toggleMask />
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">
{{ $form.password.error?.message }}
</Message>
<Password class="form-item" name="password_verify" :placeholder="t('user.password_verify')" v-model="form.password_verify" toggleMask />
<Message v-if="$form.password_verify?.invalid" severity="error" size="small" variant="simple">
{{ $form.password_verify.error?.message }}
</Message>
<Button class="form-item w-full" type="submit" severity="secondary" :loading="loading" :label="t('user.register')" />
</Form>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
@@ -25,15 +39,19 @@ import Password from 'primevue/password'
import Button from 'primevue/button'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { register } from '@/services/api/authService'
import { register, type Register } from '@/services/api/authService'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { Form, type FormResolverOptions, type FormSubmitEvent } from '@primevue/forms'
import { Message } from 'primevue'
const formRef = ref()
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const form = ref({
const form = ref<Register>({
email: '',
username: '',
password: '',
@@ -42,7 +60,10 @@ const form = ref({
const loading = ref(false)
const handleRegister = async () => {
const handleRegister = async ({ valid }: FormSubmitEvent) => {
if (!valid) {
return
}
loading.value = true
try {
await register(form.value, (route.query.invitation_key as string) ?? '')
@@ -52,6 +73,56 @@ const handleRegister = async () => {
}
loading.value = false
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
// (alphanumeric, underscore, dash, 4-15 chars)
const usernameRegex = /^[a-zA-Z0-9_-]{4,15}$/
const validatePasswordStrength = (password: string): { isValid: boolean; message: string } => {
if (password.length < 12) {
return { isValid: false, message: t('auth_validation.password_too_short') }
}
if (!/[A-Z]/.test(password)) {
return { isValid: false, message: t('auth_validation.password_no_uppercase') }
}
if (!/[a-z]/.test(password)) {
return { isValid: false, message: t('auth_validation.password_no_lowercase') }
}
if (!/\d/.test(password)) {
return { isValid: false, message: t('auth_validation.password_no_number') }
}
return { isValid: true, message: '' }
}
const resolver = ({ values }: FormResolverOptions) => {
const errors: Partial<Record<keyof Register, { message: string }[]>> = {}
console.log(values)
// Email validation
if (!emailRegex.test(values.email)) {
errors.email = [{ message: t('auth_validation.email_invalid') }]
}
// Username validation
if (!usernameRegex.test(values.username)) {
errors.username = [{ message: t('auth_validation.username_invalid') }]
}
// Password validation
const passwordValidation = validatePasswordStrength(values.password)
if (!passwordValidation.isValid) {
errors.password = [{ message: passwordValidation.message }]
}
// Password verification
if (values.password !== values.password_verify) {
errors.password_verify = [{ message: t('auth_validation.password_mismatch') }]
}
return {
errors,
}
}
</script>
<style scoped>
.form {

View File

@@ -385,6 +385,19 @@
"quote": "Quote",
"code": "Code"
},
"auth_validation": {
"email_required": "Email is required",
"email_invalid": "Please enter a valid email address",
"username_required": "Username is required",
"username_invalid": "Username must be 4-20 characters (letters, numbers, _-)",
"password_required": "Password is required",
"password_too_short": "Password must be at least 12 characters long",
"password_no_uppercase": "Password must contain at least one uppercase letter",
"password_no_lowercase": "Password must contain at least one lowercase letter",
"password_no_number": "Password must contain at least one number",
"password_verify_required": "Please confirm your password",
"password_mismatch": "Passwords do not match"
},
"error": {
"write_more_than_x_chars": "Write more than {0} characters",
"enter_at_least_x_tags": "Enter at least {0} tags",