mirror of
https://github.com/Arcadia-Solutions/arcadia.git
synced 2025-12-21 09:19:33 -06:00
feat: validation on form registration, thanks to @ValueZer0 for your
work on #299 resolves #191
This commit is contained in:
@@ -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()
|
||||
|
||||
55
backend/api/src/services/auth_service.rs
Normal file
55
backend/api/src/services/auth_service.rs
Normal 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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
111
backend/api/tests/test_auth_validations.rs
Normal file
111
backend/api/tests/test_auth_validations.rs
Normal 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(_))
|
||||
));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user