diff --git a/.env.example b/.env.example index b879ee412..57423f748 100644 --- a/.env.example +++ b/.env.example @@ -37,12 +37,20 @@ REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 -MAIL_DRIVER=smtp +# Set to 0 to disable throttle during local development (default: 60 seconds) +AUTH_PASSWORD_RESET_THROTTLE=60 + +MAIL_MAILER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null +MAIL_FROM_ADDRESS="noreply@ogamex.dev" +MAIL_FROM_NAME="${APP_NAME}" + +# To use Gmail: MAIL_HOST=smtp.gmail.com, MAIL_PORT=587, MAIL_ENCRYPTION=tls +# Other providers: Mailgun, Postmark, Resend — configure MAIL_HOST/PORT/CREDENTIALS above. REVERB_APP_ID=ogamex REVERB_APP_KEY=ogamex-key diff --git a/app/Http/Controllers/ForgotEmailController.php b/app/Http/Controllers/ForgotEmailController.php new file mode 100644 index 000000000..b37bf6a89 --- /dev/null +++ b/app/Http/Controllers/ForgotEmailController.php @@ -0,0 +1,50 @@ +validate([ + 'username' => ['required', 'string', 'max:255'], + ]); + + // Always show a success message regardless of whether the username exists, + // to avoid leaking which usernames are registered. + $user = User::where('username', $request->username)->first(); + + if ($user !== null) { + $maskedEmail = $this->maskEmail($user->email); + Mail::to($user->email)->send(new RetrieveEmailMail( + maskedEmail: $maskedEmail, + username: $user->username, + loginUrl: route('login'), + )); + } + + return redirect()->route('password.email-lookup') + ->with('status', __('t_external.forgot_email.sent')); + } + + private function maskEmail(string $email): string + { + [$local, $domain] = explode('@', $email, 2); + $visibleLocal = mb_substr($local, 0, min(2, mb_strlen($local))); + $masked = $visibleLocal . str_repeat('*', max(0, mb_strlen($local) - 2)); + + return $masked . '@' . $domain; + } +} diff --git a/app/Mail/ResetPasswordMail.php b/app/Mail/ResetPasswordMail.php new file mode 100644 index 000000000..56a80b543 --- /dev/null +++ b/app/Mail/ResetPasswordMail.php @@ -0,0 +1,33 @@ + $token, + 'email' => $this->email, + ], false)); + + Mail::to($this->email)->send(new ResetPasswordMail($resetUrl, $this->username)); + } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 766c67fde..7580f1ad9 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -46,6 +46,14 @@ public function boot(): void return Limit::perMinute(20)->by($request->session()->get('login.id')); }); + RateLimiter::for('forgot-email', function (Request $request) { + if (app()->environment('testing')) { + return Limit::none(); + } + + return Limit::perMinute(5)->by($request->ip()); + }); + Fortify::loginView(function () { return view('outgame.login'); }); @@ -71,16 +79,12 @@ public function boot(): void return $user; }); - /*Fortify::registerView(function () { - return view('auth.register'); - }); - Fortify::requestPasswordResetLinkView(function () { - return view('auth.forgot-password'); + return view('outgame.forgot-password'); }); - Fortify::resetPasswordView(function () { - return view('auth.reset-password'); - });*/ + Fortify::resetPasswordView(function ($request) { + return view('outgame.reset-password', ['request' => $request]); + }); } } diff --git a/config/auth.php b/config/auth.php index 5531825e2..e4c635cdb 100644 --- a/config/auth.php +++ b/config/auth.php @@ -95,7 +95,7 @@ 'provider' => 'users', 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), 'expire' => 60, - 'throttle' => 60, + 'throttle' => env('AUTH_PASSWORD_RESET_THROTTLE', 60), ], ], diff --git a/database/migrations/2026_04_05_193349_create_password_reset_tokens_table.php b/database/migrations/2026_04_05_193349_create_password_reset_tokens_table.php new file mode 100644 index 000000000..81a7229b0 --- /dev/null +++ b/database/migrations/2026_04_05_193349_create_password_reset_tokens_table.php @@ -0,0 +1,28 @@ +string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('password_reset_tokens'); + } +}; diff --git a/public/build/assets/outgame-b0a7e11e.js b/public/build/assets/outgame-b0a7e11e.js index 6d725a081..5fb173854 100644 --- a/public/build/assets/outgame-b0a7e11e.js +++ b/public/build/assets/outgame-b0a7e11e.js @@ -203,4 +203,4 @@ ogame.characteristics = { } };; var javascriptAvailable=true;; -$(document).ready(function(){var a={};jQuery.fn.exists=function(){return jQuery(this).length>0};$("#menu").tabs({event:"click",activate:function(j,k){a=$(j.currentTarget).attr("id");d()},beforeLoad:function(j,k){k.jqXHR.fail(function(){k.panel.html("")})}});a=$("ul#tabs a.current").attr("id");d();$("ul#tabs a").click(function(){$(".formError").hide();a=$(this).attr("id");d()});function d(){switch(a){case"tab1":$("#menu").css("background-position","15px -33px");break;case"tab2":$("#menu").css("background-position","15px -66px");break;case"tab3":$("#menu").css("background-position","15px -99px");break;case"tab4":$("#menu").css("background-position","15px -132px");break;default:break}}function c(){$("#loginBtn").addClass("open").text(JSLoca[1]);$("#login").fadeIn("fast",function(){$("#usernameLogin").focus()});if(emailOnlyLogin){$("#transition_email_only_login_dialog").dialog("open")}}function i(){$("#loginBtn").removeClass("open").text(JSLoca[0]);$("#login").fadeOut();if(emailOnlyLogin){$("#transition_email_only_login_dialog").dialog("close")}}$("#loginBtn").click(function(){$(".formError").hide();if($(this).hasClass("open")){i()}else{c()}});$("#pwLost").click(function(){$(this).attr("href","https://"+$("#serverLogin").val()+"/game/reg/mail.php")});$("#emailLost").click(function(){$(this).attr("href","https://"+$("#serverLogin").val()+"/game/index.php?page=standalone&component=retrieveEmail")});$("#resendAct").click(function(){$(".formError").hide();$("#tabs .current").removeClass("current");$("#menu").css("background-position","15px 0");$("#ajaxContent, #passwordLost, #login").hide();$("#resendLink").fadeIn();$("#loginBtn").removeClass("open").text("Login")});$("#trigger").toggle(function(){$("#selected, #language").fadeIn()},function(){$("#selected, #language").fadeOut()});$("#langHead a").click(function(){var j=$(this).attr("title");$("#selected, #displayLang").text(j)});$("#loginForm").validationEngine({validationEventTriggers:"blur",promptPosition:"centerRight",inlineValidation:true});$("#pwLostForm").validationEngine();$("#resendLinkForm").validationEngine();$("#subscribeForm").validationEngine({validationEventTriggers:"keyup blur",promptPosition:"centerRight",inlineValidation:true});$("#regSubmit").click(function(){var j=$("#username");if(j.val()===""){j.val(j.attr("placeholder"))}var k=$("#subscribeForm").validationEngine({returnIsValid:true});if(k==true){submitRegistrationForm();return false}});$("a.overlay").fancybox({beforeLoad:function(){$(".formError").hide()},type:"ajax",touch:{vertical:false}});$('a.overlay[href*="distinctions"]').fancybox({beforeLoad:function(){$(".formError").hide()},type:"ajax",baseTpl:'',touch:{vertical:false}});$("#trailer").click(function(){$(".formError").hide()});function b(l){var j="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.$!;:-_#";for(var k=0;k0};$("#menu").tabs({event:"click",activate:function(j,k){a=$(j.currentTarget).attr("id");d()},beforeLoad:function(j,k){k.jqXHR.fail(function(){k.panel.html("")})}});a=$("ul#tabs a.current").attr("id");d();$("ul#tabs a").click(function(){$(".formError").hide();a=$(this).attr("id");d()});function d(){switch(a){case"tab1":$("#menu").css("background-position","15px -33px");break;case"tab2":$("#menu").css("background-position","15px -66px");break;case"tab3":$("#menu").css("background-position","15px -99px");break;case"tab4":$("#menu").css("background-position","15px -132px");break;default:break}}function c(){$("#loginBtn").addClass("open").text(JSLoca[1]);$("#login").fadeIn("fast",function(){$("#usernameLogin").focus()});if(emailOnlyLogin){$("#transition_email_only_login_dialog").dialog("open")}}function i(){$("#loginBtn").removeClass("open").text(JSLoca[0]);$("#login").fadeOut();if(emailOnlyLogin){$("#transition_email_only_login_dialog").dialog("close")}}$("#loginBtn").click(function(){$(".formError").hide();if($(this).hasClass("open")){i()}else{c()}});$("#pwLost").click(function(){window.location.href="/forgot-password";return false;});$("#emailLost").click(function(){window.location.href="/forgot-email";return false;});$("#resendAct").click(function(){$(".formError").hide();$("#tabs .current").removeClass("current");$("#menu").css("background-position","15px 0");$("#ajaxContent, #passwordLost, #login").hide();$("#resendLink").fadeIn();$("#loginBtn").removeClass("open").text("Login")});$("#trigger").toggle(function(){$("#selected, #language").fadeIn()},function(){$("#selected, #language").fadeOut()});$("#langHead a").click(function(){var j=$(this).attr("title");$("#selected, #displayLang").text(j)});$("#loginForm").validationEngine({validationEventTriggers:"blur",promptPosition:"centerRight",inlineValidation:true});$("#pwLostForm").validationEngine();$("#resendLinkForm").validationEngine();$("#subscribeForm").validationEngine({validationEventTriggers:"keyup blur",promptPosition:"centerRight",inlineValidation:true});$("#regSubmit").click(function(){var j=$("#username");if(j.val()===""){j.val(j.attr("placeholder"))}var k=$("#subscribeForm").validationEngine({returnIsValid:true});if(k==true){submitRegistrationForm();return false}});$("a.overlay").fancybox({beforeLoad:function(){$(".formError").hide()},type:"ajax",touch:{vertical:false}});$('a.overlay[href*="distinctions"]').fancybox({beforeLoad:function(){$(".formError").hide()},type:"ajax",baseTpl:'',touch:{vertical:false}});$("#trailer").click(function(){$(".formError").hide()});function b(l){var j="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.$!;:-_#";for(var k=0;k0};$("#menu").tabs({event:"click",activate:function(j,k){a=$(j.currentTarget).attr("id");d()},beforeLoad:function(j,k){k.jqXHR.fail(function(){k.panel.html("")})}});a=$("ul#tabs a.current").attr("id");d();$("ul#tabs a").click(function(){$(".formError").hide();a=$(this).attr("id");d()});function d(){switch(a){case"tab1":$("#menu").css("background-position","15px -33px");break;case"tab2":$("#menu").css("background-position","15px -66px");break;case"tab3":$("#menu").css("background-position","15px -99px");break;case"tab4":$("#menu").css("background-position","15px -132px");break;default:break}}function c(){$("#loginBtn").addClass("open").text(JSLoca[1]);$("#login").fadeIn("fast",function(){$("#usernameLogin").focus()});if(emailOnlyLogin){$("#transition_email_only_login_dialog").dialog("open")}}function i(){$("#loginBtn").removeClass("open").text(JSLoca[0]);$("#login").fadeOut();if(emailOnlyLogin){$("#transition_email_only_login_dialog").dialog("close")}}$("#loginBtn").click(function(){$(".formError").hide();if($(this).hasClass("open")){i()}else{c()}});$("#pwLost").click(function(){$(this).attr("href","https://"+$("#serverLogin").val()+"/game/reg/mail.php")});$("#emailLost").click(function(){$(this).attr("href","https://"+$("#serverLogin").val()+"/game/index.php?page=standalone&component=retrieveEmail")});$("#resendAct").click(function(){$(".formError").hide();$("#tabs .current").removeClass("current");$("#menu").css("background-position","15px 0");$("#ajaxContent, #passwordLost, #login").hide();$("#resendLink").fadeIn();$("#loginBtn").removeClass("open").text("Login")});$("#trigger").toggle(function(){$("#selected, #language").fadeIn()},function(){$("#selected, #language").fadeOut()});$("#langHead a").click(function(){var j=$(this).attr("title");$("#selected, #displayLang").text(j)});$("#loginForm").validationEngine({validationEventTriggers:"blur",promptPosition:"centerRight",inlineValidation:true});$("#pwLostForm").validationEngine();$("#resendLinkForm").validationEngine();$("#subscribeForm").validationEngine({validationEventTriggers:"keyup blur",promptPosition:"centerRight",inlineValidation:true});$("#regSubmit").click(function(){var j=$("#username");if(j.val()===""){j.val(j.attr("placeholder"))}var k=$("#subscribeForm").validationEngine({returnIsValid:true});if(k==true){submitRegistrationForm();return false}});$("a.overlay").fancybox({beforeLoad:function(){$(".formError").hide()},type:"ajax",touch:{vertical:false}});$('a.overlay[href*="distinctions"]').fancybox({beforeLoad:function(){$(".formError").hide()},type:"ajax",baseTpl:'',touch:{vertical:false}});$("#trailer").click(function(){$(".formError").hide()});function b(l){var j="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.$!;:-_#";for(var k=0;k0};$("#menu").tabs({event:"click",activate:function(j,k){a=$(j.currentTarget).attr("id");d()},beforeLoad:function(j,k){k.jqXHR.fail(function(){k.panel.html("")})}});a=$("ul#tabs a.current").attr("id");d();$("ul#tabs a").click(function(){$(".formError").hide();a=$(this).attr("id");d()});function d(){switch(a){case"tab1":$("#menu").css("background-position","15px -33px");break;case"tab2":$("#menu").css("background-position","15px -66px");break;case"tab3":$("#menu").css("background-position","15px -99px");break;case"tab4":$("#menu").css("background-position","15px -132px");break;default:break}}function c(){$("#loginBtn").addClass("open").text(JSLoca[1]);$("#login").fadeIn("fast",function(){$("#usernameLogin").focus()});if(emailOnlyLogin){$("#transition_email_only_login_dialog").dialog("open")}}function i(){$("#loginBtn").removeClass("open").text(JSLoca[0]);$("#login").fadeOut();if(emailOnlyLogin){$("#transition_email_only_login_dialog").dialog("close")}}$("#loginBtn").click(function(){$(".formError").hide();if($(this).hasClass("open")){i()}else{c()}});$("#pwLost").click(function(){window.location.href="/forgot-password";return false;});$("#emailLost").click(function(){window.location.href="/forgot-email";return false;});$("#resendAct").click(function(){$(".formError").hide();$("#tabs .current").removeClass("current");$("#menu").css("background-position","15px 0");$("#ajaxContent, #passwordLost, #login").hide();$("#resendLink").fadeIn();$("#loginBtn").removeClass("open").text("Login")});$("#trigger").toggle(function(){$("#selected, #language").fadeIn()},function(){$("#selected, #language").fadeOut()});$("#langHead a").click(function(){var j=$(this).attr("title");$("#selected, #displayLang").text(j)});$("#loginForm").validationEngine({validationEventTriggers:"blur",promptPosition:"centerRight",inlineValidation:true});$("#pwLostForm").validationEngine();$("#resendLinkForm").validationEngine();$("#subscribeForm").validationEngine({validationEventTriggers:"keyup blur",promptPosition:"centerRight",inlineValidation:true});$("#regSubmit").click(function(){var j=$("#username");if(j.val()===""){j.val(j.attr("placeholder"))}var k=$("#subscribeForm").validationEngine({returnIsValid:true});if(k==true){submitRegistrationForm();return false}});$("a.overlay").fancybox({beforeLoad:function(){$(".formError").hide()},type:"ajax",touch:{vertical:false}});$('a.overlay[href*="distinctions"]').fancybox({beforeLoad:function(){$(".formError").hide()},type:"ajax",baseTpl:'',touch:{vertical:false}});$("#trailer").click(function(){$(".formError").hide()});function b(l){var j="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.$!;:-_#";for(var k=0;k 'Use characters only.', ], + // Forgot password page + 'forgot_password' => [ + 'title' => 'Forgot your password?', + 'description' => 'Enter your email address below and we will send you a link to reset your password.', + 'email_label' => 'Email address:', + 'submit' => 'Send reset link', + 'back_to_login' => '← Back to login', + ], + + // Reset password page + 'reset_password' => [ + 'title' => 'Reset your password', + 'email_label' => 'Email address:', + 'password_label' => 'New password:', + 'confirm_label' => 'Confirm new password:', + 'submit' => 'Reset password', + ], + + // Forgot email page + 'forgot_email' => [ + 'title' => 'Forgot your email address?', + 'description' => 'Enter your commander name and we will send a hint to the registered email address.', + 'username_label' => 'Commander name:', + 'submit' => 'Send hint', + 'back_to_login' => '← Back to login', + 'sent' => 'If a matching account was found, a hint has been sent to the registered email address.', + ], + + // Outgoing email templates + 'mail' => [ + 'reset_password' => [ + 'subject' => 'Reset your OGameX password', + 'heading' => 'Password Reset', + 'greeting' => 'Hello :username,', + 'body' => 'We received a request to reset the password for your account. Click the button below to choose a new password.', + 'cta' => 'Reset Password', + 'expiry' => 'This link will expire in 60 minutes.', + 'no_action' => 'If you did not request a password reset, no further action is required.', + 'url_fallback' => 'If you have trouble clicking the button, copy and paste the URL below into your browser:', + ], + 'retrieve_email' => [ + 'subject' => 'Your OGameX email address', + 'heading' => 'Email Address Hint', + 'greeting' => 'Hello :username,', + 'body' => 'You requested a hint for the email address associated with your account:', + 'cta' => 'Go to Login', + 'no_action' => 'If you did not make this request, you can safely ignore this email.', + ], + ], + // Universe selection characteristics tooltip texts 'universe_characteristics' => [ 'fleet_speed' => 'Fleet Speed: the higher the value, the less time you have left to react to an attack.', diff --git a/resources/lang/it/t_external.php b/resources/lang/it/t_external.php index 85f6e9a97..ed7f039e5 100644 --- a/resources/lang/it/t_external.php +++ b/resources/lang/it/t_external.php @@ -94,6 +94,56 @@ 'only_letters' => 'Usa solo lettere.', ], + // Pagina password dimenticata + 'forgot_password' => [ + 'title' => 'Hai dimenticato la password?', + 'description' => 'Inserisci il tuo indirizzo e-mail e ti invieremo un link per reimpostare la password.', + 'email_label' => 'Indirizzo e-mail:', + 'submit' => 'Invia link di ripristino', + 'back_to_login' => '← Torna al login', + ], + + // Pagina reimposta password + 'reset_password' => [ + 'title' => 'Reimposta la password', + 'email_label' => 'Indirizzo e-mail:', + 'password_label' => 'Nuova password:', + 'confirm_label' => 'Conferma nuova password:', + 'submit' => 'Reimposta password', + ], + + // Pagina e-mail dimenticata + 'forgot_email' => [ + 'title' => 'Hai dimenticato l\'indirizzo e-mail?', + 'description' => 'Inserisci il nome del tuo comandante e ti invieremo un suggerimento all\'indirizzo e-mail registrato.', + 'username_label' => 'Nome comandante:', + 'submit' => 'Invia suggerimento', + 'back_to_login' => '← Torna al login', + 'sent' => 'Se è stato trovato un account corrispondente, è stato inviato un suggerimento all\'indirizzo e-mail registrato.', + ], + + // Template e-mail in uscita + 'mail' => [ + 'reset_password' => [ + 'subject' => 'Reimposta la tua password OGameX', + 'heading' => 'Ripristino Password', + 'greeting' => 'Ciao :username,', + 'body' => 'Abbiamo ricevuto una richiesta per reimpostare la password del tuo account. Clicca il pulsante qui sotto per scegliere una nuova password.', + 'cta' => 'Reimposta Password', + 'expiry' => 'Questo link scadrà tra 60 minuti.', + 'no_action' => 'Se non hai richiesto il ripristino della password, non è necessaria alcuna ulteriore azione.', + 'url_fallback' => 'Se hai problemi a cliccare il pulsante, copia e incolla l\'URL qui sotto nel tuo browser:', + ], + 'retrieve_email' => [ + 'subject' => 'Il tuo indirizzo e-mail OGameX', + 'heading' => 'Suggerimento Indirizzo E-mail', + 'greeting' => 'Ciao :username,', + 'body' => 'Hai richiesto un suggerimento per l\'indirizzo e-mail associato al tuo account:', + 'cta' => 'Vai al Login', + 'no_action' => 'Se non hai effettuato questa richiesta, puoi ignorare questa e-mail.', + ], + ], + // Testi tooltip caratteristiche universo 'universe_characteristics' => [ 'fleet_speed' => 'Velocità flotta: maggiore è il valore, meno tempo hai per reagire a un attacco.', diff --git a/resources/lang/nl/t_external.php b/resources/lang/nl/t_external.php index 547b39488..2cea83fae 100644 --- a/resources/lang/nl/t_external.php +++ b/resources/lang/nl/t_external.php @@ -94,6 +94,56 @@ 'only_letters' => 'Gebruik alleen letters.', ], + // Pagina wachtwoord vergeten + 'forgot_password' => [ + 'title' => 'Wachtwoord vergeten?', + 'description' => 'Voer uw e-mailadres in en wij sturen u een link om uw wachtwoord opnieuw in te stellen.', + 'email_label' => 'E-mailadres:', + 'submit' => 'Resetlink versturen', + 'back_to_login' => '← Terug naar inloggen', + ], + + // Pagina wachtwoord opnieuw instellen + 'reset_password' => [ + 'title' => 'Wachtwoord opnieuw instellen', + 'email_label' => 'E-mailadres:', + 'password_label' => 'Nieuw wachtwoord:', + 'confirm_label' => 'Nieuw wachtwoord bevestigen:', + 'submit' => 'Wachtwoord opnieuw instellen', + ], + + // Pagina e-mailadres vergeten + 'forgot_email' => [ + 'title' => 'E-mailadres vergeten?', + 'description' => 'Voer uw commandantsnaam in en wij sturen een hint naar het geregistreerde e-mailadres.', + 'username_label' => 'Commandantsnaam:', + 'submit' => 'Hint versturen', + 'back_to_login' => '← Terug naar inloggen', + 'sent' => 'Als er een overeenkomend account is gevonden, is er een hint naar het geregistreerde e-mailadres gestuurd.', + ], + + // Uitgaande e-mailsjablonen + 'mail' => [ + 'reset_password' => [ + 'subject' => 'Stel uw OGameX-wachtwoord opnieuw in', + 'heading' => 'Wachtwoord Opnieuw Instellen', + 'greeting' => 'Hallo :username,', + 'body' => 'We hebben een verzoek ontvangen om het wachtwoord voor uw account opnieuw in te stellen. Klik op de knop hieronder om een nieuw wachtwoord te kiezen.', + 'cta' => 'Wachtwoord Opnieuw Instellen', + 'expiry' => 'Deze link verloopt over 60 minuten.', + 'no_action' => 'Als u geen wachtwoordreset heeft aangevraagd, is geen verdere actie vereist.', + 'url_fallback' => 'Als u problemen heeft met het klikken op de knop, kopieer en plak dan de onderstaande URL in uw browser:', + ], + 'retrieve_email' => [ + 'subject' => 'Uw OGameX e-mailadres', + 'heading' => 'Hint E-mailadres', + 'greeting' => 'Hallo :username,', + 'body' => 'U heeft een hint aangevraagd voor het e-mailadres dat aan uw account is gekoppeld:', + 'cta' => 'Ga naar Inloggen', + 'no_action' => 'Als u dit verzoek niet heeft gedaan, kunt u deze e-mail veilig negeren.', + ], + ], + // Tooltip-teksten universum kenmerken 'universe_characteristics' => [ 'fleet_speed' => 'Vlootsnelheid: hoe hoger de waarde, hoe minder tijd u heeft om te reageren op een aanval.', diff --git a/resources/views/outgame/forgot-email.blade.php b/resources/views/outgame/forgot-email.blade.php new file mode 100644 index 000000000..7a20efca9 --- /dev/null +++ b/resources/views/outgame/forgot-email.blade.php @@ -0,0 +1,53 @@ +@extends('outgame.layouts.main') + +@section('content') + + + +@endsection diff --git a/resources/views/outgame/forgot-password.blade.php b/resources/views/outgame/forgot-password.blade.php new file mode 100644 index 000000000..d16a366e2 --- /dev/null +++ b/resources/views/outgame/forgot-password.blade.php @@ -0,0 +1,53 @@ +@extends('outgame.layouts.main') + +@section('content') + + + +@endsection diff --git a/resources/views/outgame/layouts/main.blade.php b/resources/views/outgame/layouts/main.blade.php index 876b7506b..c67aac14c 100644 --- a/resources/views/outgame/layouts/main.blade.php +++ b/resources/views/outgame/layouts/main.blade.php @@ -206,9 +206,9 @@ class="browserimg ie">IE 8+ - {{ __('t_external.login.forgot_password') }} + {{ __('t_external.login.forgot_password') }}
- {{ __('t_external.login.forgot_email') }} + {{ __('t_external.login.forgot_email') }}

{!! __('t_external.login.terms_accept_html') !!}

diff --git a/resources/views/outgame/mail/reset-password.blade.php b/resources/views/outgame/mail/reset-password.blade.php new file mode 100644 index 000000000..928446c14 --- /dev/null +++ b/resources/views/outgame/mail/reset-password.blade.php @@ -0,0 +1,117 @@ + + + + + + {{ __('t_external.mail.reset_password.subject') }} + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ OGameX +

+

+ Conquer the Universe +

+
+

+ {{ __('t_external.mail.reset_password.heading') }} +

+

+ {{ __('t_external.mail.reset_password.greeting', ['username' => $username]) }} +

+

+ {{ __('t_external.mail.reset_password.body') }} +

+
+ + + + +
+ + {{ __('t_external.mail.reset_password.cta') }} + +
+
+

+ ⏳ {{ __('t_external.mail.reset_password.expiry') }} +

+

+ {{ __('t_external.mail.reset_password.no_action') }} +

+
+

+ {{ __('t_external.mail.reset_password.url_fallback') }} +

+

+ {{ $resetUrl }} +

+
+ + + + + + + +
+

+ {{ __('t_external.footer.copyright') }} +

+
+ +
+ + + diff --git a/resources/views/outgame/mail/retrieve-email.blade.php b/resources/views/outgame/mail/retrieve-email.blade.php new file mode 100644 index 000000000..3510f80b4 --- /dev/null +++ b/resources/views/outgame/mail/retrieve-email.blade.php @@ -0,0 +1,116 @@ + + + + + + {{ __('t_external.mail.retrieve_email.subject') }} + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ OGameX +

+

+ Conquer the Universe +

+
+

+ {{ __('t_external.mail.retrieve_email.heading') }} +

+

+ {{ __('t_external.mail.retrieve_email.greeting', ['username' => $username]) }} +

+

+ {{ __('t_external.mail.retrieve_email.body') }} +

+
+ + + + +
+ {{ $maskedEmail }} +
+
+ + + + +
+ + {{ __('t_external.mail.retrieve_email.cta') }} + +
+
+

+ {{ __('t_external.mail.retrieve_email.no_action') }} +

+
+ + + + + + + +
+

+ {{ __('t_external.footer.copyright') }} +

+
+ +
+ + + diff --git a/resources/views/outgame/reset-password.blade.php b/resources/views/outgame/reset-password.blade.php new file mode 100644 index 000000000..1153c6682 --- /dev/null +++ b/resources/views/outgame/reset-password.blade.php @@ -0,0 +1,72 @@ +@extends('outgame.layouts.main') + +@section('content') + + + +@endsection diff --git a/routes/web.php b/routes/web.php index 65dc6074b..e62382cb6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,6 +16,7 @@ use OGame\Http\Controllers\FacilitiesController; use OGame\Http\Controllers\FleetController; use OGame\Http\Controllers\FleetEventsController; +use OGame\Http\Controllers\ForgotEmailController; use OGame\Http\Controllers\GalaxyController; use OGame\Http\Controllers\HighscoreController; use OGame\Http\Controllers\JumpGateController; @@ -56,6 +57,10 @@ // Language switcher — accessible to both guests and authenticated users. Route::get('/lang/{lang}', [LanguageController::class, 'switchLang'])->name('language.switch'); +// Forgot email lookup (guest only). +Route::get('/forgot-email', [ForgotEmailController::class, 'show'])->name('password.email-lookup'); +Route::post('/forgot-email', [ForgotEmailController::class, 'send'])->middleware('throttle:forgot-email'); + // Public AJAX endpoints (no auth required). Route::get('/ajax/main/rules', [RulesController::class, 'ajaxRules'])->name('rules.ajax'); Route::get('/ajax/main/legal', [RulesController::class, 'ajaxLegal'])->name('legal.ajax'); diff --git a/tests/Feature/ForgotEmailTest.php b/tests/Feature/ForgotEmailTest.php new file mode 100644 index 000000000..d25ad47f3 --- /dev/null +++ b/tests/Feature/ForgotEmailTest.php @@ -0,0 +1,105 @@ +post('/logout'); + + $response = $this->get('/forgot-email'); + $response->assertStatus(200); + } + + /** + * Test that submitting an existing username sends the retrieval mail. + */ + public function testEmailSentForExistingUsername(): void + { + $this->post('/logout'); + Mail::fake(); + + $user = User::find($this->currentUserId); + + $response = $this->post('/forgot-email', [ + 'username' => $user->username, + ]); + + $response->assertRedirect(route('password.email-lookup')); + $response->assertSessionHas('status'); + + Mail::assertSent(RetrieveEmailMail::class, function (RetrieveEmailMail $mail) use ($user) { + return $mail->hasTo($user->email) && $mail->username === $user->username; + }); + } + + /** + * Test that submitting a non-existent username does NOT send a mail + * but still returns the same success redirect (no username enumeration). + */ + public function testEmailNotSentForUnknownUsername(): void + { + $this->post('/logout'); + Mail::fake(); + + $response = $this->post('/forgot-email', [ + 'username' => 'nonexistent-user-' . uniqid(), + ]); + + $response->assertRedirect(route('password.email-lookup')); + $response->assertSessionHas('status'); + + Mail::assertNotSent(RetrieveEmailMail::class); + } + + /** + * Test that the masked email is correct (first 2 chars visible, rest masked). + */ + public function testMaskedEmailInMailContent(): void + { + $this->post('/logout'); + Mail::fake(); + + $user = User::find($this->currentUserId); + + $this->post('/forgot-email', [ + 'username' => $user->username, + ]); + + Mail::assertSent(RetrieveEmailMail::class, function (RetrieveEmailMail $mail) use ($user) { + // The masked email should start with the first 2 chars of the local part. + [$local, $domain] = explode('@', $user->email, 2); + $expectedPrefix = mb_substr($local, 0, min(2, mb_strlen($local))); + + return str_starts_with($mail->maskedEmail, $expectedPrefix) + && str_contains($mail->maskedEmail, '@' . $domain) + && str_contains($mail->maskedEmail, '***'); + }); + } + + /** + * Test that submitting without a username returns a validation error. + */ + public function testValidationRequiresUsername(): void + { + $this->post('/logout'); + + $response = $this->post('/forgot-email', [ + 'username' => '', + ]); + + $response->assertSessionHasErrors('username'); + } +} diff --git a/tests/Feature/ForgotPasswordTest.php b/tests/Feature/ForgotPasswordTest.php new file mode 100644 index 000000000..54c9bd013 --- /dev/null +++ b/tests/Feature/ForgotPasswordTest.php @@ -0,0 +1,120 @@ +post('/logout'); + + $response = $this->get('/forgot-password'); + $response->assertStatus(200); + } + + /** + * Test that requesting a reset link for an existing email sends a mail. + */ + public function testResetLinkSentForExistingEmail(): void + { + $this->post('/logout'); + Mail::fake(); + + $user = User::find($this->currentUserId); + + $response = $this->post('/forgot-password', [ + 'email' => $user->email, + ]); + + $response->assertRedirect(); + + Mail::assertSent(ResetPasswordMail::class, function (ResetPasswordMail $mail) use ($user) { + return $mail->hasTo($user->email) && $mail->username === $user->username; + }); + } + + /** + * Test that requesting a reset link for a non-existent email does not send a mail + * but still redirects (no user enumeration). + */ + public function testResetLinkNotSentForUnknownEmail(): void + { + $this->post('/logout'); + Mail::fake(); + + $response = $this->post('/forgot-password', [ + 'email' => 'nonexistent-user-' . uniqid() . '@example.com', + ]); + + $response->assertRedirect(); + + Mail::assertNotSent(ResetPasswordMail::class); + } + + /** + * Test that the reset-password form renders with a valid token. + */ + public function testResetPasswordPageLoadsWithToken(): void + { + $this->post('/logout'); + + $user = User::find($this->currentUserId); + $token = Password::createToken($user); + + $response = $this->get('/reset-password/' . $token . '?email=' . urlencode($user->email)); + $response->assertStatus(200); + } + + /** + * Test that submitting a valid reset completes successfully. + */ + public function testPasswordCanBeResetWithValidToken(): void + { + $this->post('/logout'); + + $user = User::find($this->currentUserId); + $token = Password::createToken($user); + + $response = $this->post('/reset-password', [ + 'token' => $token, + 'email' => $user->email, + 'password' => 'new-password-123', + 'password_confirmation' => 'new-password-123', + ]); + + $response->assertRedirect('/login'); + } + + /** + * Test that an invalid token is rejected. + */ + public function testPasswordResetFailsWithInvalidToken(): void + { + $this->post('/logout'); + + $user = User::find($this->currentUserId); + + $response = $this->post('/reset-password', [ + 'token' => 'invalid-token-value', + 'email' => $user->email, + 'password' => 'new-password-123', + 'password_confirmation' => 'new-password-123', + ]); + + // Should redirect back with errors (invalid token). + $response->assertRedirect(); + $response->assertSessionHasErrors(); + } +}