소스 검색

feat: adiciona logica de recuperar senha

ebagabee 1 개월 전
부모
커밋
5f34146eeb

+ 1 - 0
app/Enums/UserTypeEnum.php

@@ -9,6 +9,7 @@ enum UserTypeEnum: string
     use EnumHelper;
 
     case ADMIN = 'ADMIN';
+    case ADMIN_FRANCHISEE = 'ADMIN_FRANCHISEE';
     case USER = 'USER';
     case GUEST = 'GUEST';
 }

+ 49 - 0
app/Http/Controllers/AuthController.php

@@ -3,7 +3,10 @@
 namespace App\Http\Controllers;
 
 use App\Http\Requests\AuthRequest;
+use App\Http\Requests\ForgotPasswordRequest;
 use App\Http\Requests\RefreshTokenRequest;
+use App\Http\Requests\ResetPasswordRequest;
+use App\Http\Requests\VerifyPasswordCodeRequest;
 use Illuminate\Http\JsonResponse;
 use App\Http\Resources\AuthResource;
 use App\Services\AuthService;
@@ -47,6 +50,52 @@ public function login(AuthRequest $request): JsonResponse
         );
     }
 
+    public function forgotPassword(ForgotPasswordRequest $request): JsonResponse
+    {
+        $validated = $request->validated();
+
+        $sent = $this->authService->forgotPassword(email: $validated['email']);
+
+        if (!$sent) {
+            return $this->errorResponse(message: __('auth.email_not_found'), code: 422);
+        }
+
+        return $this->successResponse(message: __('auth.password_reset_sent'));
+    }
+
+    public function verifyPasswordCode(VerifyPasswordCodeRequest $request): JsonResponse
+    {
+        $validated = $request->validated();
+
+        $valid = $this->authService->verifyPasswordCode(
+            email: $validated['email'],
+            code: $validated['code'],
+        );
+
+        if (!$valid) {
+            return $this->errorResponse(message: __('auth.invalid_code'), code: 422);
+        }
+
+        return $this->successResponse(message: __('auth.code_verified'));
+    }
+
+    public function resetPassword(ResetPasswordRequest $request): JsonResponse
+    {
+        $validated = $request->validated();
+
+        $reset = $this->authService->resetPassword(
+            email: $validated['email'],
+            code: $validated['code'],
+            password: $validated['password'],
+        );
+
+        if (!$reset) {
+            return $this->errorResponse(message: __('auth.invalid_code'), code: 422);
+        }
+
+        return $this->successResponse(message: __('auth.password_reset_success'));
+    }
+
     public function logout(Request $request): JsonResponse
     {
         $this->authService->logout();

+ 20 - 0
app/Http/Requests/ForgotPasswordRequest.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class ForgotPasswordRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    public function rules(): array
+    {
+        return [
+            'email' => ['required', 'string', 'email'],
+        ];
+    }
+}

+ 22 - 0
app/Http/Requests/ResetPasswordRequest.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class ResetPasswordRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    public function rules(): array
+    {
+        return [
+            'email'    => ['required', 'string', 'email'],
+            'code'     => ['required', 'string', 'size:6'],
+            'password' => ['required', 'string', 'min:8', 'confirmed'],
+        ];
+    }
+}

+ 21 - 0
app/Http/Requests/VerifyPasswordCodeRequest.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class VerifyPasswordCodeRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    public function rules(): array
+    {
+        return [
+            'email' => ['required', 'string', 'email'],
+            'code'  => ['required', 'string', 'size:6'],
+        ];
+    }
+}

+ 30 - 0
app/Mail/PasswordResetCodeMail.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Mail;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Mail\Mailable;
+use Illuminate\Mail\Mailables\Content;
+use Illuminate\Mail\Mailables\Envelope;
+use Illuminate\Queue\SerializesModels;
+
+class PasswordResetCodeMail extends Mailable
+{
+    use Queueable, SerializesModels;
+
+    public function __construct(public string $code, public string $recoveryLink) {}
+
+    public function envelope(): Envelope
+    {
+        return new Envelope(
+            subject: 'Código de verificação - ' . config('app.name'),
+        );
+    }
+
+    public function content(): Content
+    {
+        return new Content(
+            view: 'emails.password-reset-code',
+        );
+    }
+}

+ 65 - 0
app/Services/AuthService.php

@@ -3,12 +3,14 @@
 namespace App\Services;
 
 use App\Enums\UserTypeEnum;
+use App\Mail\PasswordResetCodeMail;
 use App\Models\User;
 use App\Models\PersonalAccessToken;
 use Carbon\Carbon;
 use Exception;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Mail;
 use Illuminate\Support\Str;
 
 class AuthService
@@ -75,6 +77,69 @@ public function refresh(string $refreshToken): ?array
         ];
     }
 
+    public function forgotPassword(string $email): bool
+    {
+        $user = User::where('email', $email)->first();
+
+        if (!$user) {
+            return false;
+        }
+
+        $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
+
+        DB::table('password_reset_tokens')->updateOrInsert(
+            ['email' => $email],
+            [
+                'token'      => $code,
+                'created_at' => now(),
+                'expires_at' => now()->addMinutes(30),
+            ]
+        );
+
+        $recoveryLink = config('app.franchisee_url') . '/recovery-password?email=' . urlencode($email);
+
+        Mail::to($email)->send(new PasswordResetCodeMail($code, $recoveryLink));
+
+        return true;
+    }
+
+    public function resetPassword(string $email, string $code, string $password): bool
+    {
+        if (!$this->verifyPasswordCode($email, $code)) {
+            return false;
+        }
+
+        $user = User::where('email', $email)->first();
+
+        if (!$user) {
+            return false;
+        }
+
+        $user->update(['password' => $password]);
+
+        DB::table('password_reset_tokens')->where('email', $email)->delete();
+
+        return true;
+    }
+
+    public function verifyPasswordCode(string $email, string $code): bool
+    {
+        $record = DB::table('password_reset_tokens')
+            ->where('email', $email)
+            ->where('token', $code)
+            ->first();
+
+        if (!$record) {
+            return false;
+        }
+
+        if (Carbon::parse($record->expires_at)->isPast()) {
+            return false;
+        }
+
+        return true;
+    }
+
     public function logout(): void
     {
         $user = Auth::user();

+ 2 - 0
config/app.php

@@ -54,6 +54,8 @@
 
     'url' => env('APP_URL', 'http://localhost'),
 
+    'franchisee_url' => env('FRANCHISEE_URL', 'http://localhost:9000'),
+
     /*
     |--------------------------------------------------------------------------
     | Application Timezone

+ 22 - 0
database/migrations/2026_03_30_000001_add_expires_at_to_password_reset_tokens_table.php

@@ -0,0 +1,22 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('password_reset_tokens', function (Blueprint $table) {
+            $table->timestamp('expires_at')->nullable()->after('created_at');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('password_reset_tokens', function (Blueprint $table) {
+            $table->dropColumn('expires_at');
+        });
+    }
+};

+ 22 - 0
database/seeders/FranchiseeAdminSeeder.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace Database\Seeders;
+
+use App\Models\User;
+use App\Enums\UserTypeEnum;
+use Illuminate\Database\Seeder;
+
+class FranchiseeAdminSeeder extends Seeder
+{
+    public function run(): void
+    {
+        User::firstOrCreate(
+            ['email' => 'gh.alvesantos@gmail.com'],
+            [
+                'name'      => 'Gabriel Alves',
+                'password'  => 'S@ft2080.',
+                'user_type' => UserTypeEnum::ADMIN_FRANCHISEE,
+            ]
+        );
+    }
+}

+ 5 - 0
lang/en/auth.php

@@ -21,4 +21,9 @@
     'already_logged_out' => 'User already logged out',
     'unauthorized' => 'Unauthorized',
     'session_expired' => 'Session expired',
+    'password_reset_sent' => 'Verification code sent to the email',
+    'email_not_found' => 'E-mail not found in the system',
+    'invalid_code' => 'Invalid or expired code',
+    'code_verified' => 'Code verified successfully',
+    'password_reset_success' => 'Password reset successfully',
 ];

+ 5 - 0
lang/es/auth.php

@@ -21,4 +21,9 @@
     'already_logged_out' => 'El usuario ya ha cerrado sesión',
     'unauthorized' => 'No autorizado',
     'session_expired' => 'Sesión caducada',
+    'password_reset_sent' => 'Código de verificación enviado al correo electrónico',
+    'email_not_found' => 'Correo electrónico no encontrado en el sistema',
+    'invalid_code' => 'Código inválido o expirado',
+    'code_verified' => 'Código verificado con éxito',
+    'password_reset_success' => 'Contraseña restablecida con éxito',
 ];

+ 5 - 0
lang/pt/auth.php

@@ -21,4 +21,9 @@
     'already_logged_out' => 'Usuário já desconectado',
     'unauthorized' => 'Não autorizado',
     'session_expired' => 'Sessão expirada',
+    'password_reset_sent' => 'Código de verificação enviado para o e-mail',
+    'email_not_found' => 'E-mail não encontrado no sistema',
+    'invalid_code' => 'Código inválido ou expirado',
+    'code_verified' => 'Código verificado com sucesso',
+    'password_reset_success' => 'Senha redefinida com sucesso',
 ];

+ 41 - 0
resources/views/emails/password-reset-code.blade.php

@@ -0,0 +1,41 @@
+<!DOCTYPE html>
+<html lang="pt-BR">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Código de verificação</title>
+    <style>
+        body { font-family: Arial, sans-serif; background-color: #f4f4f4; margin: 0; padding: 0; }
+        .container { max-width: 500px; margin: 40px auto; background: #ffffff; border-radius: 12px; padding: 40px; }
+        .logo { text-align: center; margin-bottom: 32px; }
+        .title { font-size: 22px; font-weight: bold; color: #1a5c38; text-align: center; margin-bottom: 16px; }
+        .description { font-size: 14px; color: #555; text-align: center; margin-bottom: 32px; }
+        .code-box { text-align: center; background: #f0f9f4; border: 2px solid #1a5c38; border-radius: 10px; padding: 24px; margin-bottom: 24px; }
+        .code { font-size: 40px; font-weight: bold; letter-spacing: 12px; color: #1a5c38; }
+        .divider { border: none; border-top: 1px solid #e0e0e0; margin: 24px 0; }
+        .btn { display: block; width: fit-content; margin: 0 auto; background-color: #1a5c38; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-size: 15px; font-weight: bold; }
+        .expiry { font-size: 12px; color: #888; text-align: center; margin-top: 16px; }
+        .footer { font-size: 12px; color: #aaa; text-align: center; margin-top: 32px; }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <div class="logo">
+            <strong style="font-size: 24px; color: #1a5c38;">Ginástica do Cérebro</strong>
+        </div>
+        <div class="title">Redefinição de senha</div>
+        <div class="description">
+            Use o código abaixo para verificar sua identidade e redefinir sua senha.<br>
+            Este código é válido por <strong>30 minutos</strong>.
+        </div>
+        <div class="code-box">
+            <div class="code">{{ $code }}</div>
+        </div>
+        <hr class="divider">
+        <div class="description">Ou clique no botão abaixo para acessar a página de recuperação de senha diretamente:</div>
+        <a href="{{ $recoveryLink }}" class="btn">Redefinir minha senha</a>
+        <div class="expiry" style="margin-top: 24px;">Se você não solicitou a redefinição de senha, ignore este e-mail.</div>
+        <div class="footer">&copy; {{ date('Y') }} {{ config('app.name') }}. Todos os direitos reservados.</div>
+    </div>
+</body>
+</html>

+ 6 - 0
routes/noAuthRoutes/auth.php

@@ -6,3 +6,9 @@
 Route::post('/login', [AuthController::class, 'login']);
 
 Route::post('/refresh', [AuthController::class, 'refresh']);
+
+Route::post('/forgot-password', [AuthController::class, 'forgotPassword']);
+
+Route::post('/forgot-password/verify-code', [AuthController::class, 'verifyPasswordCode']);
+
+Route::post('/reset-password', [AuthController::class, 'resetPassword']);