Merge remote-tracking branch 'origin/main'

This commit is contained in:
Andras Bacsai
2026-08-20 09:34:13 +02:00
7 changed files with 238 additions and 66 deletions
+99 -51
View File
@@ -8,12 +8,15 @@ use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
@@ -95,61 +98,105 @@ class Controller extends BaseController
return response()->json(['message' => 'Transactional emails are not active'], 400);
}
public function link()
public function link(): View|RedirectResponse
{
$token = request()->get('token');
if (is_string($token) && $token !== '') {
try {
$decrypted = Crypt::decryptString($token);
} catch (DecryptException) {
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
if (! str_contains($decrypted, '@@@')) {
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
$payload = explode('@@@', $decrypted, 3);
if (count($payload) === 3) {
[$email, $invitationUuid, $password] = $payload;
} else {
[$email, $password] = $payload;
$invitationUuid = null;
}
$email = Str::lower($email);
$user = User::whereEmail($email)->first();
if (! $user) {
return redirect()->route('login');
}
$invitation = TeamInvitation::query()
->where('email', $email)
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
->first();
if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
if (Hash::check($password, $user->password)) {
$team = $invitation->team;
if (! $user->teams()->where('team_id', $team->id)->exists()) {
$user->teams()->attach($team->id, ['role' => $invitation->role]);
}
$invitation->delete();
$user->forceFill([
'password' => Hash::make(Str::random(64)),
])->save();
Auth::login($user);
session(['currentTeam' => $team]);
return redirect()->route('dashboard');
}
$credentials = is_string($token) ? $this->magicLinkCredentials($token) : null;
if (! $credentials) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
return redirect()->route('login')->with('error', 'Invalid credentials.');
[$user, $invitation] = $credentials;
return view('invitation.accept', [
'invitation' => $invitation,
'team' => $invitation->team,
'alreadyMember' => $user->teams()->where('team_id', $invitation->team_id)->exists(),
'formAction' => route('auth.link.accept'),
'token' => $token,
]);
}
public function acceptLink(Request $request): RedirectResponse
{
$token = $request->input('token');
if (! is_string($token)) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
$acceptedInvitation = DB::transaction(function () use ($token) {
$credentials = $this->magicLinkCredentials($token, lockForUpdate: true);
if (! $credentials) {
return null;
}
[$user, $invitation] = $credentials;
$team = $invitation->team;
if (! $user->teams()->where('team_id', $team->id)->exists()) {
$user->teams()->attach($team->id, ['role' => $invitation->role]);
}
$user->forceFill([
'password' => Hash::make(Str::random(64)),
])->save();
$invitation->delete();
return [$user, $team];
});
if (! $acceptedInvitation) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
[$user, $team] = $acceptedInvitation;
Auth::login($user);
session(['currentTeam' => $team]);
return redirect()->route('dashboard');
}
/**
* @return array{0: User, 1: TeamInvitation}|null
*/
private function magicLinkCredentials(string $token, bool $lockForUpdate = false): ?array
{
if ($token === '') {
return null;
}
try {
$decrypted = Crypt::decryptString($token);
} catch (DecryptException) {
return null;
}
$payload = explode('@@@', $decrypted, 3);
if (count($payload) === 3) {
[$email, $invitationUuid, $password] = $payload;
} elseif (count($payload) === 2) {
[$email, $password] = $payload;
$invitationUuid = null;
} else {
return null;
}
$email = Str::lower($email);
$user = User::query()->where('email', $email)->first();
$invitationQuery = TeamInvitation::query()
->where('email', $email)
->when($lockForUpdate, fn ($query) => $query->lockForUpdate());
$invitation = $invitationUuid
? $invitationQuery->where('uuid', $invitationUuid)->first()
: $invitationQuery->get()->first(
fn (TeamInvitation $invitation) => $this->invitationLinkMatchesToken($invitation, $token)
);
if (! $user || ! $invitation || $invitation->hasExpired() || ! $this->invitationLinkMatchesToken($invitation, $token)) {
return null;
}
return Hash::check($password, $user->password) ? [$user, $invitation] : null;
}
private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool
@@ -185,6 +232,7 @@ class Controller extends BaseController
'invitation' => $invitation,
'team' => $invitation->team,
'alreadyMember' => $alreadyMember,
'formAction' => route('team.invitation.accept', $invitation->uuid),
]);
}
+7 -4
View File
@@ -33,11 +33,9 @@ class TeamInvitation extends Model
return TeamInvitation::whereTeamId(currentTeam()->id);
}
public function isValid()
public function isValid(): bool
{
$createdAt = $this->created_at;
$diff = $createdAt->diffInDays(now());
if ($diff <= config('constants.invitation.link.expiration_days')) {
if (! $this->hasExpired()) {
return true;
} else {
$this->delete();
@@ -49,4 +47,9 @@ class TeamInvitation extends Model
return false;
}
}
public function hasExpired(): bool
{
return $this->created_at->diffInDays(now()) > config('constants.invitation.link.expiration_days');
}
}
+7
View File
@@ -152,6 +152,13 @@ class FortifyServiceProvider extends ServiceProvider
return Limit::perMinute(5)->by($email.'|'.$realIp);
});
RateLimiter::for('magic-link', function (Request $request) {
$realIp = $request->server('REMOTE_ADDR') ?? $request->ip();
$token = (string) $request->input('token');
return Limit::perMinute(5)->by(hash('sha256', $token.'|'.$realIp));
});
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});