Skip to content

Commit b5fa87f

Browse files
committed
feat: Implement email verification and configure production environment for HTTPS with Let's Encrypt.
1 parent 79b6867 commit b5fa87f

7 files changed

Lines changed: 188 additions & 20 deletions

File tree

app/Http/Controllers/Auth/VerifyEmailController.php

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,40 @@
33
namespace App\Http\Controllers\Auth;
44

55
use App\Http\Controllers\Controller;
6+
use App\Models\User;
67
use Illuminate\Auth\Events\Verified;
7-
use Illuminate\Foundation\Auth\EmailVerificationRequest;
8+
use Illuminate\Http\Request;
89
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Support\Facades\Auth;
911

1012
class VerifyEmailController extends Controller
1113
{
1214
/**
1315
* Mark the authenticated user's email address as verified.
1416
*/
15-
public function __invoke(EmailVerificationRequest $request): RedirectResponse
17+
public function __invoke(Request $request, $id, $hash): RedirectResponse
1618
{
17-
if ($request->user()->hasVerifiedEmail()) {
18-
return redirect()->intended(route('explore', absolute: false).'?verified=1');
19+
$user = User::find($id);
20+
21+
if (!$user) {
22+
abort(404);
1923
}
2024

21-
if ($request->user()->markEmailAsVerified()) {
22-
/** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */
23-
$user = $request->user();
25+
if (!hash_equals((string) $hash, sha1($user->getEmailForVerification()))) {
26+
abort(403);
27+
}
2428

29+
if ($user->hasVerifiedEmail()) {
30+
return redirect()->intended(route('explore', absolute: false) . '?verified=1');
31+
}
32+
33+
if ($user->markEmailAsVerified()) {
2534
event(new Verified($user));
35+
36+
// Auto-login the user since they just verified
37+
Auth::login($user);
2638
}
2739

28-
return redirect()->intended(route('explore', absolute: false).'?verified=1');
40+
return redirect()->intended(route('explore', absolute: false) . '?verified=1');
2941
}
30-
}
42+
}

app/Notifications/CustomVerifyEmail.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ protected function verificationUrl($notifiable)
1414
{
1515
return URL::temporarySignedRoute(
1616
'verification.verify',
17-
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
17+
Carbon::now()->addMinutes(30),
1818
['id' => $notifiable->getKey(), 'hash' => sha1($notifiable->getEmailForVerification())]
1919
);
2020
}
@@ -30,5 +30,5 @@ public function toMail($notifiable)
3030
return (new VerifyEmailCustom($url, $name))
3131
->to($notifiable->email, $name);
3232
}
33-
33+
3434
}

docker-compose.prod.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,27 @@ services:
4646
restart: unless-stopped
4747
ports:
4848
- "80:80"
49+
- "443:443"
4950
volumes:
5051
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
5152
- public_data:/var/www/public
5253
- storage_data:/var/www/storage
54+
- ./docker/certbot/conf:/etc/letsencrypt
55+
- ./docker/certbot/www:/var/www/certbot
5356
depends_on:
5457
- app
5558
networks:
5659
- app-network
5760

61+
certbot:
62+
image: certbot/certbot
63+
container_name: dropmix-certbot
64+
volumes:
65+
- ./docker/certbot/conf:/etc/letsencrypt
66+
- ./docker/certbot/www:/var/www/certbot
67+
networks:
68+
- app-network
69+
5870
queue:
5971
image: dropmix-app
6072
container_name: dropmix-queue

docker/nginx/default.conf

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
server {
22
listen 80;
3+
server_name dropmixr.es www.dropmixr.es;
4+
5+
location /.well-known/acme-challenge/ {
6+
root /var/www/certbot;
7+
}
8+
9+
location / {
10+
return 301 https://$host$request_uri;
11+
}
12+
}
13+
14+
server {
15+
listen 443 ssl;
16+
server_name dropmixr.es www.dropmixr.es;
17+
18+
ssl_certificate /etc/letsencrypt/live/dropmixr.es/fullchain.pem;
19+
ssl_certificate_key /etc/letsencrypt/live/dropmixr.es/privkey.pem;
20+
include /etc/letsencrypt/options-ssl-nginx.conf;
21+
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
22+
323
index index.php index.html;
424
error_log /var/log/nginx/error.log;
525
access_log /var/log/nginx/access.log;

docker/nginx/init-letsencrypt.sh

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#!/bin/bash
2+
3+
if ! [ -x "$(command -v docker-compose)" ]; then
4+
echo 'Error: docker-compose is not installed.' >&2
5+
exit 1
6+
fi
7+
8+
domains=(dropmixr.es www.dropmixr.es)
9+
rsa_key_size=4096
10+
data_path="./docker/certbot"
11+
email="[email protected]" # PLEASE UPDATE THIS TO YOUR REAL EMAIL
12+
staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits
13+
14+
if [ -d "$data_path" ]; then
15+
read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision
16+
if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
17+
exit
18+
fi
19+
fi
20+
21+
if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then
22+
echo "### Downloading recommended TLS parameters ..."
23+
mkdir -p "$data_path/conf"
24+
curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf"
25+
curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem"
26+
echo
27+
fi
28+
29+
echo "### Creating dummy certificate for $domains ..."
30+
path="/etc/letsencrypt/live/$domains"
31+
mkdir -p "$data_path/conf/live/$domains"
32+
docker-compose -f docker-compose.prod.yml run --rm --entrypoint "\
33+
openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
34+
-keyout '$path/privkey.pem' \
35+
-out '$path/fullchain.pem' \
36+
-subj '/CN=localhost'" certbot
37+
echo
38+
39+
echo "### Starting nginx ..."
40+
docker-compose -f docker-compose.prod.yml up --force-recreate -d webserver
41+
echo
42+
43+
echo "### Deleting dummy certificate for $domains ..."
44+
docker-compose -f docker-compose.prod.yml run --rm --entrypoint "\
45+
rm -Rf /etc/letsencrypt/live/$domains && \
46+
rm -Rf /etc/letsencrypt/archive/$domains && \
47+
rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot
48+
echo
49+
50+
echo "### Requesting Let's Encrypt certificate for $domains ..."
51+
#Join $domains to -d args
52+
domain_args=""
53+
for domain in "${domains[@]}"; do
54+
domain_args="$domain_args -d $domain"
55+
done
56+
57+
# Select appropriate email arg
58+
case "$email" in
59+
"") email_arg="--register-unsafely-without-email" ;;
60+
*) email_arg="-m $email" ;;
61+
esac
62+
63+
# Enable staging mode if needed
64+
if [ $staging != "0" ]; then staging_arg="--staging"; fi
65+
66+
docker-compose -f docker-compose.prod.yml run --rm --entrypoint "\
67+
certbot certonly --webroot -w /var/www/certbot \
68+
$staging_arg \
69+
$email_arg \
70+
$domain_args \
71+
--rsa-key-size $rsa_key_size \
72+
--agree-tos \
73+
--force-renewal" certbot
74+
echo
75+
76+
echo "### Reloading nginx ..."
77+
docker-compose -f docker-compose.prod.yml exec webserver nginx -s reload

routes/auth.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@
1010
use App\Http\Controllers\Auth\VerifyEmailController;
1111
use Illuminate\Support\Facades\Route;
1212

13+
Route::get('verify-email/{id}/{hash}', [VerifyEmailController::class, '__invoke'])
14+
->middleware(['signed', 'throttle:6,1'])
15+
->name('verification.verify');
16+
1317
Route::middleware('guest')->group(function () {
18+
19+
1420
Route::get('register', [RegisteredUserController::class, 'create'])
1521
->name('register');
1622

@@ -38,9 +44,7 @@
3844
Route::get('verify-email', EmailVerificationPromptController::class)
3945
->name('verification.notice');
4046

41-
Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
42-
->middleware(['signed', 'throttle:6,1'])
43-
->name('verification.verify');
47+
4448

4549
Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
4650
->middleware('throttle:6,1')

tests/Feature/Auth/EmailVerificationTest.php

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,23 +22,66 @@ public function test_email_verification_screen_can_be_rendered()
2222
$response->assertStatus(200);
2323
}
2424

25-
public function test_email_can_be_verified()
25+
public function test_email_can_be_verified_and_autologs_in()
2626
{
2727
$user = User::factory()->unverified()->create();
2828

2929
Event::fake();
3030

3131
$verificationUrl = URL::temporarySignedRoute(
3232
'verification.verify',
33-
now()->addMinutes(60),
33+
now()->addMinutes(30),
3434
['id' => $user->id, 'hash' => sha1($user->email)]
3535
);
3636

37-
$response = $this->actingAs($user)->get($verificationUrl);
37+
// Access without being logged in
38+
$response = $this->get($verificationUrl);
3839

3940
Event::assertDispatched(Verified::class);
4041
$this->assertTrue($user->fresh()->hasVerifiedEmail());
41-
$response->assertRedirect(route('explore', absolute: false).'?verified=1');
42+
$this->assertAuthenticatedAs($user);
43+
$response->assertRedirect(route('explore', absolute: false) . '?verified=1');
44+
}
45+
46+
public function test_verified_user_cannot_autologin_with_verification_link()
47+
{
48+
$user = User::factory()->create(); // Verified by default
49+
$this->assertTrue($user->hasVerifiedEmail());
50+
51+
$verificationUrl = URL::temporarySignedRoute(
52+
'verification.verify',
53+
now()->addMinutes(30),
54+
['id' => $user->id, 'hash' => sha1($user->email)]
55+
);
56+
57+
// Ensure we are guest
58+
$this->assertGuest();
59+
60+
// Access link
61+
$response = $this->get($verificationUrl);
62+
63+
// Should be redirected but NOT logged in
64+
$response->assertRedirect(route('explore', absolute: false) . '?verified=1');
65+
$this->assertGuest();
66+
}
67+
68+
public function test_verification_link_expires_after_30_minutes()
69+
{
70+
$user = User::factory()->unverified()->create();
71+
72+
$verificationUrl = URL::temporarySignedRoute(
73+
'verification.verify',
74+
now()->addMinutes(30),
75+
['id' => $user->id, 'hash' => sha1($user->email)]
76+
);
77+
78+
// Travel to future
79+
$this->travel(31)->minutes();
80+
81+
$response = $this->get($verificationUrl);
82+
83+
$response->assertStatus(403);
84+
$this->assertFalse($user->fresh()->hasVerifiedEmail());
4285
}
4386

4487
public function test_email_is_not_verified_with_invalid_hash()
@@ -47,11 +90,11 @@ public function test_email_is_not_verified_with_invalid_hash()
4790

4891
$verificationUrl = URL::temporarySignedRoute(
4992
'verification.verify',
50-
now()->addMinutes(60),
93+
now()->addMinutes(30),
5194
['id' => $user->id, 'hash' => sha1('wrong-email')]
5295
);
5396

54-
$this->actingAs($user)->get($verificationUrl);
97+
$this->get($verificationUrl);
5598

5699
$this->assertFalse($user->fresh()->hasVerifiedEmail());
57100
}

0 commit comments

Comments
 (0)