I am working on a blogging application in Laravel 8 .
I have added "live validation" to the registration form with JavaScript. For password strength, I use Zxcvbn .
The registration form template:
<form method="POST" action="{{ route('register') }}">
@csrf
<div class="row mb-2">
<label for="first_name" class="col-md-12">{{ __('First name') }}</label>
<div class="col-md-12 @error('first_name') has-error @enderror">
<input id="first_name" type="text" placeholder="First name"
class="form-control @error('first_name') is-invalid @enderror" name="first_name"
value="{{ old('first_name') }}" autocomplete="first_name" autofocus>
@error('first_name')
<span class="invalid-feedback" role="alert">
<strong>{{ $message }}</strong>
</span>
@enderror
</div>
</div>
<div class="row mb-2">
<label for="last_name" class="col-md-12">{{ __('Last name') }}</label>
<div class="col-md-12 @error('last_name') has-error @enderror">
<input id="last_name" type="text" placeholder="Last name"
class="form-control @error('last_name') is-invalid @enderror" name="last_name"
value="{{ old('last_name') }}" autocomplete="last_name" autofocus>
@error('last_name')
<span class="invalid-feedback" role="alert">
<strong>{{ $message }}</strong>
</span>
@enderror
</div>
</div>
<div class="row mb-2">
<label for="email" class="col-md-12">{{ __('Email address') }}</label>
<div class="col-md-12 @error('email') has-error @enderror">
<input id="email" type="email" placeholder="Email address"
class="form-control @error('email') is-invalid @enderror" name="email"
value="{{ old('email') }}" autocomplete="email">
@error('email')
<span class="invalid-feedback" role="alert">
<strong>{{ $message }}</strong>
</span>
@enderror
</div>
</div>
<div class="row mb-2">
<label for="password" class="col-md-12">{{ __('Password') }}</label>
<div class="col-md-12 password-container @error('password') has-error @enderror">
<input id="password" type="password" class="form-control @error('password') is-invalid @enderror"
name="password" placeholder="Password" autocomplete="new-password">
@error('password')
<span class="invalid-feedback" role="alert">
<strong>{{ $message }}</strong>
</span>
@enderror
<div class="progress password-strength-progress mt-1" id="password-strength">
<div class="progress-bar" role="progressbar"></div>
</div>
<div class="position-absolute w-100">
<small id="password-strength-text" class="form-text text-muted"></small>
</div>
</div>
</div>
<div class="row mb-2">
<label for="password-confirm" class="col-md-12">{{ __('Confirm password') }}</label>
<div class="col-md-12 @error('password_confirmation') has-error @enderror">
<input id="password-confirm" type="password" placeholder="Password again"
class="form-control @error('password_confirmation') is-invalid @enderror"
name="password_confirmation" autocomplete="password_confirmation">
@error('password_confirmation')
<span class="invalid-feedback" role="alert">
<strong>{{ $message }}</strong>
</span>
@enderror
</div>
</div>
<div class="row mb-1 @error('accept') has-error @enderror">
<div class="d-flex pb-2">
<input type="checkbox" name="accept" id="accept">
<label class="px-1" for="accept">
Accept the <a href="{{ url('/page/1') }}" class="text-success">Terms and conditions</a>
</label>
@error('accept')
<span class="invalid-feedback accept" role="alert">
<strong>{{ $message }}</strong>
</span>
@enderror
</div>
</div>
<div class="row mb-0">
<div class="col-md-12">
<button type="submit" class="w-100 btn btn-success">
{{ __('Register') }}
</button>
</div>
</div>
</form>
The JavaScript:
<script src="{{ asset('lib/password-strength/password-strength.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const form = document.querySelector('form');
const fields = {
firstName: form.querySelector('#first_name'),
lastName: form.querySelector('#last_name'),
email: form.querySelector('#email'),
password: form.querySelector('#password'),
passwordConfirm: form.querySelector('#password-confirm'),
accept: form.querySelector('#accept')
};
const messages = {
first_name_required: 'First name is required.',
last_name_required: 'Last name is required.',
email_required: 'Email is required.',
email_invalid: 'Enter a valid email address.',
email_exists: 'This email is already in use.',
password_required: 'Password is required.',
password_min_length: 'Password must be at least 6 characters.',
password_pattern: 'Include uppercase and lowercase letters, at least one number and one symbol.',
password_match: 'Passwords must match.',
accept: 'You must accept the terms.'
};
const strengthWrapper = document.getElementById('password-strength');
const strengthBar = strengthWrapper.querySelector('.progress-bar');
const strengthText = document.getElementById('password-strength-text');
const emailRegex = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[#?!@$%^&*-]).*$/;
const levels = [
{ text: 'Very weak', class: 'bg-danger', width: '20%' },
{ text: 'Weak', class: 'bg-warning', width: '40%' },
{ text: 'Fair', class: 'bg-info', width: '60%' },
{ text: 'Strong', class: 'bg-primary', width: '80%' },
{ text: 'Very strong', class: 'bg-success', width: '100%' }
];
let emailUnique = false;
let emailTimeout = null;
const getVal = (el) => el.value.trim();
const isChecked = (el) => el.checked;
function toggleError(input, valid, message) {
const group = input.closest('.col-md-12, .row');
let error = group.querySelector('.invalid-feedback');
if (!error) {
error = document.createElement('span');
error.className = 'invalid-feedback';
error.setAttribute('role', 'alert');
error.innerHTML = `<strong>${message}</strong>`;
input.type === 'checkbox'
? group.appendChild(error)
: input.insertAdjacentElement('afterend', error);
} else {
error.querySelector('strong').textContent = message;
}
input.classList.toggle('is-invalid', !valid);
group.classList.toggle('has-error', !valid);
error.style.display = valid ? 'none' : 'block';
}
function validateFirstName() {
const val = getVal(fields.firstName);
toggleError(fields.firstName, !!val, messages.first_name_required);
}
function validateLastName() {
const val = getVal(fields.lastName);
toggleError(fields.lastName, !!val, messages.last_name_required);
}
function validateEmail() {
clearTimeout(emailTimeout);
const val = getVal(fields.email);
if (!val) {
toggleError(fields.email, false, messages.email_required);
emailUnique = false;
return;
}
if (!emailRegex.test(val)) {
toggleError(fields.email, false, messages.email_invalid);
emailUnique = false;
return;
}
toggleError(fields.email, true, '');
emailTimeout = setTimeout(() => {
fetch('{{ route('check.email') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Accept': 'application/json'
},
body: JSON.stringify({ email: val })
})
.then(res => res.json())
.then(data => {
emailUnique = !!data.valid;
toggleError(fields.email, emailUnique, data.message || messages.email_exists);
})
.catch(() => {
emailUnique = false;
toggleError(fields.email, false, 'Unable to validate email uniqueness.');
});
}, 500);
}
function validatePassword() {
const val = fields.password.value;
toggleError(fields.password, !!val, messages.password_required);
strengthWrapper.style.display = 'block';
strengthBar.className = 'progress-bar';
strengthBar.style.width = '0%';
strengthText.textContent = '';
if (!val) return;
if (val.length < 6) {
toggleError(fields.password, false, messages.password_min_length);
return;
}
if (!passwordRegex.test(val)) {
toggleError(fields.password, false, messages.password_pattern);
strengthBar.classList.add('bg-danger');
strengthBar.style.width = levels[0].width;
strengthText.textContent = levels[0].text;
return;
} else {
toggleError(fields.password, true, '');
}
if (typeof zxcvbn === 'function') {
const score = Math.max(0, Math.min(zxcvbn(val).score, 4));
const level = levels[score];
strengthBar.classList.add(level.class);
strengthBar.style.width = level.width;
strengthText.textContent = level.text;
}
if (fields.passwordConfirm.value) {
validatePasswordConfirm();
}
}
function validatePasswordConfirm() {
const match = fields.passwordConfirm.value === fields.password.value;
toggleError(fields.passwordConfirm, match, messages.password_match);
}
function validateAccept() {
toggleError(fields.accept, isChecked(fields.accept), messages.accept);
}
function validateFormOnSubmit(e) {
let valid = true;
validateFirstName();
validateLastName();
validateEmail();
validatePassword();
validatePasswordConfirm();
validateAccept();
if (!getVal(fields.firstName) || !getVal(fields.lastName)) valid = false;
if (!emailRegex.test(getVal(fields.email)) || !emailUnique) valid = false;
if (!fields.password.value || fields.password.value.length < 6 || !passwordRegex.test(fields.password.value)) valid = false;
if (fields.passwordConfirm.value !== fields.password.value) valid = false;
if (!isChecked(fields.accept)) valid = false;
if (!valid) {
e.preventDefault();
e.stopPropagation();
}
}
fields.firstName.addEventListener('input', validateFirstName);
fields.lastName.addEventListener('input', validateLastName);
fields.email.addEventListener('input', validateEmail);
fields.password.addEventListener('input', validatePassword);
fields.passwordConfirm.addEventListener('input', validatePasswordConfirm);
fields.accept.addEventListener('change', validateAccept);
form.addEventListener('submit', validateFormOnSubmit);
});
</script>
I check that the email is unique from the RegisterController controller:
public function checkEmail(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => ['required', 'email', 'unique:users,email'],
]);
if ($validator->fails()) {
return response()->json([
'valid' => false,
'message' => 'The email address is already in use.',
]);
}
return response()->json([
'valid' => true,
'message' => '',
]);
}
Questions:
- Is there any room for optimisation of the code, especially the JavaScript part?
- Are there any security issues?
- Can you see bugs I might have missed?
4 Answers 4
Toggling the notification message(s)...
input.classList.toggle('is-invalid', !valid);
group.classList.toggle('has-error', !valid);
error.style.display = valid ? 'none' : 'block';
...could be implemented using CSS classes by defining the display
property of CSS selector .has-error .invalid-feedback
to block
...
.has-error .invalid-feedback { display: block; }
...while the display
property of the CSS selector .invalid-feedback
should be defined to none
...
.invalid-feedback { display: none; }
...that way the toggling function changes to...
function toggleError(input, valid, message) {
const group = input.closest('.col-md-12, .row');
let error = group.querySelector('.invalid-feedback');
if (!error) {
error = document.createElement('span');
error.className = 'invalid-feedback';
error.setAttribute('role', 'alert');
error.innerHTML = `<strong>${message}</strong>`;
input.type === 'checkbox'
? group.appendChild(error)
: input.insertAdjacentElement('afterend', error);
} else {
error.querySelector('strong').textContent = message;
}
input.classList.toggle('is-invalid', !valid);
group.classList.toggle('has-error', !valid);
}
This is a review for the generated HTML page based on the laravel code snippet from description.
Without knowing laravel technical details I tend to think the HTML generated by following markup...
<input id="last_name"
type="text"
placeholder="Last name"
class="form-control @error('last_name') is-invalid @enderror"
name="last_name"
value="{{ old('last_name') }}"
autocomplete="last_name"
autofocus>
...is uncompliant with HTML recommendations, particularly the above input
tag should be closed...
<input id="last_name"
type="text"
placeholder="Last name"
class="form-control @error('last_name') is-invalid @enderror"
name="last_name"
value="{{ old('last_name') }}"
autocomplete="last_name"
autofocus />
...or...
<input id="last_name"
type="text"
placeholder="Last name"
class="form-control @error('last_name') is-invalid @enderror"
name="last_name"
value="{{ old('last_name') }}"
autocomplete="last_name"
autofocus></input>
The markup validation service of W3C would list the required improvements when run over the resulting HTML.
With same disclaimer without knowing laravel technical details {{ old('...') }}
seems susceptible of cross site scripting (XSS) vulnerabilities. The HTML and JavaScript special characters should be escaped to prevent XSS vulnerabilities in the generated HTML page. It might be that laravel built-ins include special characters escaping while it might be equally true they do not include that type of escaping.
Case-insensitive unique
This could be a moot point, but you want to make sure that the email address is indeed unique, and treated in a case-sensitive manner. Thus, [email protected] should be treated the same as [email protected].
I'm not familiar with Laravel, so I did a quick search on the validation routines. It turns out that you should be okay, except maybe in some edge cases, depending on the underlying database type and table collation.
You could normalize all email addresses to lowercase anyway, before running validation and writing to the database. So you don't have to worry about possible database intricacies.
Reference: How to Use Laravel Unique Rule for Accurate Validation?
-
\$\begingroup\$ I tested the email uniqueness. It is not case sensitive. \$\endgroup\$Razvan Zamfir– Razvan Zamfir2025年07月01日 08:48:06 +00:00Commented Jul 1 at 8:48
-
1\$\begingroup\$
[email protected]
is not the same address as[email protected]
, in general. Only the domain part is known to be case-insensitive. \$\endgroup\$Toby Speight– Toby Speight2025年07月01日 11:57:36 +00:00Commented Jul 1 at 11:57 -
\$\begingroup\$ @Toby Speight yes that's a good point. On some systems (Postfix?) or middleware (Cyrus?) the leftmost part of the E-mail could potentially be treated in a case-sensitive manner. I can't remember a recent example though. An edge case maybe, but worth keeping in mind. \$\endgroup\$Kate– Kate2025年07月01日 13:11:14 +00:00Commented Jul 1 at 13:11
3. Can you see bugs I might have missed?
Let us look at this validation:
$validator = Validator::make($request->all(), [ 'email' => ['required', 'email', 'unique:users,email'], ]); if ($validator->fails()) { return response()->json([ 'valid' => false, 'message' => 'The email address is already in use.', ]); }
While the front-end code is designed to prevent such cases, the validator could fail if the value for email
was missing or not formatted as an email address 1 . Bearing in mind they would need to sent the appropriate headers including X-CSRF-TOKEN
, a user could still submit an XHR manually in the browser console or similar means. In any case the message sent in the response would have the value 'The email address is already in use.'
. As I've mentioned previously consider creating a FormRequest subclass - then the error messages can easily be customized.
-
\$\begingroup\$ How would you implement that? \$\endgroup\$Razvan Zamfir– Razvan Zamfir2025年07月02日 07:50:51 +00:00Commented Jul 2 at 7:50
-
\$\begingroup\$ I apologize - this answer will hopefully be more explanatory. \$\endgroup\$2025年07月03日 02:58:30 +00:00Commented Jul 3 at 2:58
me-too@foo/bar.baz
is not a valid email, but at least you're on the safe side and never reject valid emails, maybe it's not that bad), and password regex is also weird (seems like it does express "has at least one of each of a-z, A-Z, 0-9 and characters", but I won't bet my money on that, a simple for loop would be much more comprehensible), I don't know Laravel at all, but will try to post an answer about JS side tonight. And what doeszxcvbn
mean? Is that a function from the referencedpassword-strength.js
? Add a comment at least, obfuscated names aren't helpful \$\endgroup\$emailRegex
, thanks! \$\endgroup\$Qwerty0?
is a better password thanXq8hBAJ6QlMGPvNi4QgTIxCd5
. This makes no sense. Also see xkcd.com/936 \$\endgroup\$