diff --git a/.env.example b/.env.example index 6fb3de6..2201f44 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,16 @@ -APP_NAME=Laravel +APP_NAME=Verde APP_ENV=local APP_KEY= APP_DEBUG=true APP_TIMEZONE=UTC -APP_URL=http://localhost +APP_URL=http://localhost:8000 +APP_DISPLAY_TIMEZONE=Asia/Manila APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US APP_MAINTENANCE_DRIVER=file -# APP_MAINTENANCE_STORE=database PHP_CLI_SERVER_WORKERS=4 @@ -21,12 +21,12 @@ LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug -DB_CONNECTION=sqlite -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=laravel -# DB_USERNAME=root -# DB_PASSWORD= +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=verde +DB_USERNAME=root +DB_PASSWORD= SESSION_DRIVER=database SESSION_LIFETIME=120 @@ -64,3 +64,11 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# SMS / OTP — log driver writes to laravel.log; switch to semaphore in prod. +SMS_DRIVER=log +SEMAPHORE_API_KEY= +SEMAPHORE_SENDER_NAME=VERDE +OTP_LENGTH=6 +OTP_TTL_MINUTES=10 +OTP_RESEND_COOLDOWN_SECONDS=60 diff --git a/CLAUDE.md b/CLAUDE.md index 2a14139..c560efc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,8 @@ conventions. ## Stack - Laravel 11.51, PHP 8.4 -- MySQL 9.2 via DBngin (host 127.0.0.1, root user, no password, db `verde`) +- MySQL 9.2 via DBngin (host 127.0.0.1, root user, no password, db `verde`, + test db `verde_testing`) - Sanctum for token auth - Spatie: laravel-permission, laravel-activitylog, laravel-model-states - matanyadaev/laravel-eloquent-spatial for MySQL POINT/POLYGON @@ -16,6 +17,13 @@ php artisan serve --host=127.0.0.1 --port=8000 ``` Health: `GET http://127.0.0.1:8000/api/v1/health` +## Tests +``` +php artisan test +``` +Tests use the `verde_testing` database (configured in phpunit.xml) with +`RefreshDatabase`. SMS driver in tests is `fake` (an in-memory recorder). + ## DBngin MySQL CLI The mysql binary is not on PATH. Use: ``` @@ -44,6 +52,26 @@ Implemented in `App\Http\Responses\ApiResponse`. Controllers extend - Controllers in `app/Http/Controllers/Api/V1/...` - Route names follow `api.v1..` +### Auth +- Sanctum bearer tokens for mobile (token in `Authorization: Bearer `) +- Auth endpoints under `/api/v1/auth/*` — see `routes/api.php` +- Role middleware alias `role:[,...]` (defined in + `App\Http\Middleware\EnsureUserHasRole`, registered in `bootstrap/app.php`) +- Spatie roles seeded by `RoleSeeder` (admin/resident/driver/helper/scanner/ + store_partner). The `users.role` column is the discriminator; Spatie roles + mirror it for permission checks. + +### OTP +- Codes stored hashed in `otp_codes` (purpose, destination, attempts cap = 5, + expires_at, consumed_at). See `App\Models\OtpCode`. +- `App\Services\Otp\OtpService` issues + verifies codes with TTL/cooldown + controlled by `services.otp.*` config. +- SMS goes through `App\Services\Sms\SmsService` contract. Drivers: + `LogSmsService` (default — writes to log), `SemaphoreSmsService` (prod), + `FakeSmsService` (tests). Driver chosen by `SMS_DRIVER` env. +- In `local`/`testing` environments, register/forgot/resend responses include + `data.debug_code` for easy manual testing — strip in `production`. + ### Exceptions Global handlers in `bootstrap/app.php` ensure ValidationException, AuthenticationException, NotFoundHttpException, and HttpExceptionInterface @@ -64,11 +92,21 @@ try/catch for these — let them bubble. ### Testing Use feature tests with `RefreshDatabase`. Aim for happy path + 2-3 error cases -per endpoint. Tests live in `tests/Feature/Api/V1/`. +per endpoint. Tests live in `tests/Feature/Api/V1/`. For routes that send SMS, +bind `FakeSmsService` via `$this->app->instance(SmsService::class, ...)` in +`setUp()`. ## Module Status -- [x] Module 1: Foundation & Auth — base scaffold -- [ ] Module 1 remaining: register/login/logout/OTP endpoints, role seeders, - auth feature tests -- [ ] Module 2: Geographic Data +- [x] Module 1: Foundation & Auth — complete + - register / login / logout / refresh / me + - forgot-password / reset-password + - OTP verify / resend (Semaphore + log + fake drivers) + - Role middleware + RoleSeeder + AdminUserSeeder + - 27 feature tests passing +- [ ] Module 2: Geographic Data (PSGC import + barangay polygons) - [ ] Module 3+: see `../docs/development-roadmap.md` + +## Default Admin (after `db:seed`) +- email: `admin@verde.local` +- password: `password` +- phone: `+639000000000` diff --git a/app/Http/Controllers/Api/V1/Auth/ForgotPasswordController.php b/app/Http/Controllers/Api/V1/Auth/ForgotPasswordController.php new file mode 100644 index 0000000..f97a198 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Auth/ForgotPasswordController.php @@ -0,0 +1,34 @@ +validated('phone'); + $user = User::where('phone', $phone)->first(); + + $debugCode = null; + if ($user) { + $issue = $otp->issue( + destination: $phone, + purpose: OtpCode::PURPOSE_PASSWORD_RESET, + user: $user, + ); + $debugCode = $issue->plainCode; + } + + return $this->ok([ + 'otp_destination' => $phone, + 'debug_code' => app()->environment('local', 'testing') ? $debugCode : null, + ], 'If an account exists for that phone, a reset code has been sent.'); + } +} diff --git a/app/Http/Controllers/Api/V1/Auth/LoginController.php b/app/Http/Controllers/Api/V1/Auth/LoginController.php new file mode 100644 index 0000000..83706a3 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Auth/LoginController.php @@ -0,0 +1,50 @@ +validated(); + + $user = User::query() + ->when($data['email'] ?? null, fn ($q, $email) => $q->where('email', $email)) + ->when($data['phone'] ?? null, fn ($q, $phone) => $q->where('phone', $phone)) + ->first(); + + if (! $user || ! Hash::check($data['password'], $user->password)) { + return $this->fail('Invalid credentials', null, 401); + } + + if ($user->status === User::STATUS_SUSPENDED) { + return $this->forbidden('Account suspended'); + } + + if ($user->status === User::STATUS_PENDING) { + return $this->fail( + 'Account pending verification. Verify the OTP sent during registration.', + ['account' => ['Verify your phone before logging in.']], + 403, + ); + } + + $user->forceFill(['last_login_at' => now()])->save(); + + $deviceName = $data['device_name'] ?? $request->userAgent() ?? 'unknown'; + $token = $user->createToken($deviceName); + + return $this->ok([ + 'user' => new UserResource($user), + 'token' => $token->plainTextToken, + 'token_type' => 'Bearer', + ], 'Login successful'); + } +} diff --git a/app/Http/Controllers/Api/V1/Auth/LogoutController.php b/app/Http/Controllers/Api/V1/Auth/LogoutController.php new file mode 100644 index 0000000..a354777 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Auth/LogoutController.php @@ -0,0 +1,22 @@ +user()->currentAccessToken(); + + if ($token instanceof PersonalAccessToken) { + $token->delete(); + } + + return $this->ok(null, 'Logged out'); + } +} diff --git a/app/Http/Controllers/Api/V1/Auth/MeController.php b/app/Http/Controllers/Api/V1/Auth/MeController.php new file mode 100644 index 0000000..bbb05dc --- /dev/null +++ b/app/Http/Controllers/Api/V1/Auth/MeController.php @@ -0,0 +1,18 @@ +ok([ + 'user' => new UserResource($request->user()), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Auth/RefreshTokenController.php b/app/Http/Controllers/Api/V1/Auth/RefreshTokenController.php new file mode 100644 index 0000000..c37016f --- /dev/null +++ b/app/Http/Controllers/Api/V1/Auth/RefreshTokenController.php @@ -0,0 +1,34 @@ +user(); + $current = $user->currentAccessToken(); + + $deviceName = $current instanceof PersonalAccessToken + ? $current->name + : ($request->userAgent() ?? 'unknown'); + + if ($current instanceof PersonalAccessToken) { + $current->delete(); + } + + $token = $user->createToken($deviceName); + + return $this->ok([ + 'user' => new UserResource($user), + 'token' => $token->plainTextToken, + 'token_type' => 'Bearer', + ], 'Token refreshed'); + } +} diff --git a/app/Http/Controllers/Api/V1/Auth/RegisterController.php b/app/Http/Controllers/Api/V1/Auth/RegisterController.php new file mode 100644 index 0000000..d9e6a2d --- /dev/null +++ b/app/Http/Controllers/Api/V1/Auth/RegisterController.php @@ -0,0 +1,52 @@ +validated(); + + $user = DB::transaction(function () use ($data) { + $user = User::create([ + 'first_name' => $data['first_name'], + 'middle_name' => $data['middle_name'] ?? null, + 'last_name' => $data['last_name'], + 'email' => $data['email'], + 'phone' => $data['phone'], + 'password' => Hash::make($data['password']), + 'role' => $data['role'], + 'status' => User::STATUS_PENDING, + 'preferred_language' => $data['preferred_language'] ?? 'en', + ]); + + $user->assignRole($data['role']); + + return $user; + }); + + $issue = $otp->issue( + destination: $user->phone, + purpose: OtpCode::PURPOSE_REGISTER, + user: $user, + ); + + return $this->created([ + 'user' => new UserResource($user), + 'otp_sent' => $issue->issued, + 'otp_destination' => $user->phone, + 'debug_code' => app()->environment('local', 'testing') ? $issue->plainCode : null, + ], 'Registration successful. Verify the OTP sent to your phone.'); + } +} diff --git a/app/Http/Controllers/Api/V1/Auth/ResendOtpController.php b/app/Http/Controllers/Api/V1/Auth/ResendOtpController.php new file mode 100644 index 0000000..78d15dc --- /dev/null +++ b/app/Http/Controllers/Api/V1/Auth/ResendOtpController.php @@ -0,0 +1,37 @@ +validated(); + $user = User::where('phone', $data['phone'])->first(); + + $issue = $otp->issue( + destination: $data['phone'], + purpose: $data['purpose'], + user: $user, + ); + + if (! $issue->issued) { + return $this->fail( + 'Please wait before requesting another code', + ['retry_after' => [$issue->retryAfterSeconds]], + 429, + ); + } + + return $this->ok([ + 'otp_destination' => $data['phone'], + 'debug_code' => app()->environment('local', 'testing') ? $issue->plainCode : null, + ], 'OTP sent'); + } +} diff --git a/app/Http/Controllers/Api/V1/Auth/ResetPasswordController.php b/app/Http/Controllers/Api/V1/Auth/ResetPasswordController.php new file mode 100644 index 0000000..ac6dd4c --- /dev/null +++ b/app/Http/Controllers/Api/V1/Auth/ResetPasswordController.php @@ -0,0 +1,56 @@ +validated(); + $user = User::where('phone', $data['phone'])->first(); + + if (! $user) { + return $this->fail('Invalid code', ['code' => ['not_found']], 422); + } + + $result = $otp->verify($data['phone'], OtpCode::PURPOSE_PASSWORD_RESET, $data['code']); + + if (! $result->isOk()) { + return $this->fail( + $this->messageFor($result->status), + ['code' => [$result->status]], + 422, + ); + } + + $user->forceFill(['password' => Hash::make($data['password'])])->save(); + + PersonalAccessToken::query() + ->where('tokenable_type', $user->getMorphClass()) + ->where('tokenable_id', $user->id) + ->delete(); + + return $this->ok(null, 'Password reset successful. Please log in.'); + } + + private function messageFor(string $status): string + { + return match ($status) { + OtpVerifyResult::STATUS_INVALID => 'Invalid code', + OtpVerifyResult::STATUS_EXPIRED => 'Code expired', + OtpVerifyResult::STATUS_EXHAUSTED => 'Too many attempts. Request a new code.', + OtpVerifyResult::STATUS_NOT_FOUND => 'No active code for this destination', + default => 'Reset failed', + }; + } +} diff --git a/app/Http/Controllers/Api/V1/Auth/VerifyOtpController.php b/app/Http/Controllers/Api/V1/Auth/VerifyOtpController.php new file mode 100644 index 0000000..a5a5e69 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Auth/VerifyOtpController.php @@ -0,0 +1,66 @@ +validated(); + + $result = $otp->verify($data['phone'], $data['purpose'], $data['code']); + + if (! $result->isOk()) { + return $this->fail( + $this->messageFor($result->status), + ['code' => [$result->status]], + 422, + ); + } + + $user = User::where('phone', $data['phone'])->first(); + + if ($user && in_array($data['purpose'], [OtpCode::PURPOSE_REGISTER, OtpCode::PURPOSE_PHONE_VERIFY], true)) { + $user->forceFill([ + 'phone_verified_at' => now(), + 'status' => $user->status === User::STATUS_PENDING ? User::STATUS_ACTIVE : $user->status, + ])->save(); + } + + $payload = [ + 'verified' => true, + 'purpose' => $data['purpose'], + ]; + + if ($data['purpose'] === OtpCode::PURPOSE_LOGIN && $user) { + $token = $user->createToken($request->userAgent() ?? 'otp-login'); + $payload['user'] = new UserResource($user); + $payload['token'] = $token->plainTextToken; + $payload['token_type'] = 'Bearer'; + } elseif ($user) { + $payload['user'] = new UserResource($user); + } + + return $this->ok($payload, 'OTP verified'); + } + + private function messageFor(string $status): string + { + return match ($status) { + OtpVerifyResult::STATUS_INVALID => 'Invalid code', + OtpVerifyResult::STATUS_EXPIRED => 'Code expired', + OtpVerifyResult::STATUS_EXHAUSTED => 'Too many attempts. Request a new code.', + OtpVerifyResult::STATUS_NOT_FOUND => 'No active code for this destination', + default => 'Verification failed', + }; + } +} diff --git a/app/Http/Middleware/EnsureUserHasRole.php b/app/Http/Middleware/EnsureUserHasRole.php new file mode 100644 index 0000000..d0c3999 --- /dev/null +++ b/app/Http/Middleware/EnsureUserHasRole.php @@ -0,0 +1,26 @@ +user(); + + if (! $user) { + return ApiResponse::error('Unauthenticated', null, Response::HTTP_UNAUTHORIZED); + } + + if (! in_array($user->role, $roles, true)) { + return ApiResponse::error('Forbidden — role not permitted', null, Response::HTTP_FORBIDDEN); + } + + return $next($request); + } +} diff --git a/app/Http/Requests/Auth/ForgotPasswordRequest.php b/app/Http/Requests/Auth/ForgotPasswordRequest.php new file mode 100644 index 0000000..ff31be8 --- /dev/null +++ b/app/Http/Requests/Auth/ForgotPasswordRequest.php @@ -0,0 +1,20 @@ + ['required', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 0000000..88ae99d --- /dev/null +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,23 @@ + ['required_without:phone', 'nullable', 'email'], + 'phone' => ['required_without:email', 'nullable', 'string'], + 'password' => ['required', 'string'], + 'device_name' => ['nullable', 'string', 'max:100'], + ]; + } +} diff --git a/app/Http/Requests/Auth/RegisterRequest.php b/app/Http/Requests/Auth/RegisterRequest.php new file mode 100644 index 0000000..2ee28c3 --- /dev/null +++ b/app/Http/Requests/Auth/RegisterRequest.php @@ -0,0 +1,36 @@ + ['required', 'string', 'max:100'], + 'middle_name' => ['nullable', 'string', 'max:100'], + 'last_name' => ['required', 'string', 'max:100'], + 'email' => ['required', 'email', 'max:191', Rule::unique('users', 'email')->whereNull('deleted_at')], + 'phone' => ['required', 'string', 'regex:/^\+?[0-9]{10,15}$/', Rule::unique('users', 'phone')->whereNull('deleted_at')], + 'password' => ['required', 'confirmed', Password::min(8)->letters()->numbers()], + 'role' => ['required', Rule::in([ + User::ROLE_RESIDENT, + User::ROLE_DRIVER, + User::ROLE_HELPER, + User::ROLE_SCANNER, + User::ROLE_STORE_PARTNER, + ])], + 'preferred_language' => ['nullable', 'string', 'in:en,tl,ceb'], + ]; + } +} diff --git a/app/Http/Requests/Auth/ResendOtpRequest.php b/app/Http/Requests/Auth/ResendOtpRequest.php new file mode 100644 index 0000000..49b27df --- /dev/null +++ b/app/Http/Requests/Auth/ResendOtpRequest.php @@ -0,0 +1,27 @@ + ['required', 'string'], + 'purpose' => ['required', Rule::in([ + OtpCode::PURPOSE_REGISTER, + OtpCode::PURPOSE_LOGIN, + OtpCode::PURPOSE_PHONE_VERIFY, + ])], + ]; + } +} diff --git a/app/Http/Requests/Auth/ResetPasswordRequest.php b/app/Http/Requests/Auth/ResetPasswordRequest.php new file mode 100644 index 0000000..54ed0a1 --- /dev/null +++ b/app/Http/Requests/Auth/ResetPasswordRequest.php @@ -0,0 +1,23 @@ + ['required', 'string'], + 'code' => ['required', 'string', 'min:4', 'max:8'], + 'password' => ['required', 'confirmed', Password::min(8)->letters()->numbers()], + ]; + } +} diff --git a/app/Http/Requests/Auth/VerifyOtpRequest.php b/app/Http/Requests/Auth/VerifyOtpRequest.php new file mode 100644 index 0000000..297f9fd --- /dev/null +++ b/app/Http/Requests/Auth/VerifyOtpRequest.php @@ -0,0 +1,28 @@ + ['required', 'string'], + 'code' => ['required', 'string', 'min:4', 'max:8'], + 'purpose' => ['required', Rule::in([ + OtpCode::PURPOSE_REGISTER, + OtpCode::PURPOSE_LOGIN, + OtpCode::PURPOSE_PHONE_VERIFY, + ])], + ]; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php new file mode 100644 index 0000000..e00a2a5 --- /dev/null +++ b/app/Http/Resources/UserResource.php @@ -0,0 +1,27 @@ + $this->uuid, + 'email' => $this->email, + 'phone' => $this->phone, + 'first_name' => $this->first_name, + 'middle_name' => $this->middle_name, + 'last_name' => $this->last_name, + 'role' => $this->role, + 'status' => $this->status, + 'preferred_language' => $this->preferred_language, + 'email_verified_at' => $this->email_verified_at?->toIso8601String(), + 'phone_verified_at' => $this->phone_verified_at?->toIso8601String(), + 'last_login_at' => $this->last_login_at?->toIso8601String(), + ]; + } +} diff --git a/app/Models/OtpCode.php b/app/Models/OtpCode.php new file mode 100644 index 0000000..5063533 --- /dev/null +++ b/app/Models/OtpCode.php @@ -0,0 +1,62 @@ + 'datetime', + 'consumed_at' => 'datetime', + 'attempts' => 'integer', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + public function isConsumed(): bool + { + return $this->consumed_at !== null; + } + + public function isExhausted(): bool + { + return $this->attempts >= self::MAX_ATTEMPTS; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..c58044b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,21 +2,30 @@ namespace App\Providers; +use App\Services\Sms\FakeSmsService; +use App\Services\Sms\LogSmsService; +use App\Services\Sms\SemaphoreSmsService; +use App\Services\Sms\SmsService; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { - /** - * Register any application services. - */ public function register(): void { - // + $this->app->singleton(SmsService::class, function ($app) { + $driver = config('services.sms.driver', 'log'); + + return match ($driver) { + 'semaphore' => new SemaphoreSmsService( + apiKey: (string) config('services.semaphore.api_key'), + senderName: (string) config('services.semaphore.sender_name', 'VERDE'), + ), + 'fake' => new FakeSmsService(), + default => new LogSmsService(), + }; + }); } - /** - * Bootstrap any application services. - */ public function boot(): void { // diff --git a/app/Services/Otp/OtpIssueResult.php b/app/Services/Otp/OtpIssueResult.php new file mode 100644 index 0000000..d7f8f6e --- /dev/null +++ b/app/Services/Otp/OtpIssueResult.php @@ -0,0 +1,27 @@ +where('destination', $destination) + ->where('purpose', $purpose) + ->latest('id') + ->first(); + + if ($latest && $latest->created_at->diffInSeconds(now()) < $cooldown && ! $latest->isConsumed()) { + $retryAfter = $cooldown - (int) $latest->created_at->diffInSeconds(now()); + + return OtpIssueResult::throttled(max(1, $retryAfter)); + } + + $code = $this->generateCode(); + $ttl = (int) config('services.otp.ttl_minutes', 10); + + $otp = OtpCode::create([ + 'user_id' => $user?->id, + 'channel' => $channel, + 'destination' => $destination, + 'purpose' => $purpose, + 'code_hash' => Hash::make($code), + 'attempts' => 0, + 'expires_at' => now()->addMinutes($ttl), + ]); + + $message = $this->renderMessage($purpose, $code, $ttl); + $sendResult = $channel === OtpCode::CHANNEL_SMS + ? $this->sms->send($destination, $message) + : null; + + return OtpIssueResult::issued($otp, $code, $sendResult); + } + + public function verify(string $destination, string $purpose, string $code): OtpVerifyResult + { + $otp = OtpCode::query() + ->where('destination', $destination) + ->where('purpose', $purpose) + ->whereNull('consumed_at') + ->latest('id') + ->first(); + + if (! $otp) { + return OtpVerifyResult::notFound(); + } + + if ($otp->isExpired()) { + return OtpVerifyResult::expired(); + } + + if ($otp->isExhausted()) { + return OtpVerifyResult::exhausted(); + } + + $otp->increment('attempts'); + + if (! Hash::check($code, $otp->code_hash)) { + return OtpVerifyResult::invalid($otp->fresh()); + } + + $otp->forceFill(['consumed_at' => now()])->save(); + + return OtpVerifyResult::ok($otp); + } + + private function generateCode(): string + { + $length = max(4, (int) config('services.otp.length', 6)); + $max = (10 ** $length) - 1; + + return str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT); + } + + private function renderMessage(string $purpose, string $code, int $ttlMinutes): string + { + $reason = match ($purpose) { + OtpCode::PURPOSE_REGISTER => 'verify your Verde registration', + OtpCode::PURPOSE_LOGIN => 'log in to Verde', + OtpCode::PURPOSE_PASSWORD_RESET => 'reset your Verde password', + OtpCode::PURPOSE_PHONE_VERIFY => 'verify your Verde phone number', + default => 'continue with Verde', + }; + + return "Your Verde code is {$code} (valid {$ttlMinutes} min). Use to {$reason}. Do not share."; + } +} diff --git a/app/Services/Otp/OtpVerifyResult.php b/app/Services/Otp/OtpVerifyResult.php new file mode 100644 index 0000000..c72cefc --- /dev/null +++ b/app/Services/Otp/OtpVerifyResult.php @@ -0,0 +1,49 @@ +status === self::STATUS_OK; + } + + public static function ok(OtpCode $otp): self + { + return new self(self::STATUS_OK, $otp); + } + + public static function invalid(?OtpCode $otp): self + { + return new self(self::STATUS_INVALID, $otp); + } + + public static function expired(): self + { + return new self(self::STATUS_EXPIRED); + } + + public static function exhausted(): self + { + return new self(self::STATUS_EXHAUSTED); + } + + public static function notFound(): self + { + return new self(self::STATUS_NOT_FOUND); + } +} diff --git a/app/Services/Sms/FakeSmsService.php b/app/Services/Sms/FakeSmsService.php new file mode 100644 index 0000000..8721c0a --- /dev/null +++ b/app/Services/Sms/FakeSmsService.php @@ -0,0 +1,35 @@ + */ + public Collection $messages; + + public function __construct() + { + $this->messages = collect(); + } + + public function send(string $to, string $message): SmsResult + { + $id = (string) Str::uuid(); + $this->messages->push(['to' => $to, 'message' => $message, 'id' => $id]); + + return SmsResult::success($id); + } + + public function lastMessageTo(string $to): ?string + { + return $this->messages->last(fn ($m) => $m['to'] === $to)['message'] ?? null; + } + + public function reset(): void + { + $this->messages = collect(); + } +} diff --git a/app/Services/Sms/LogSmsService.php b/app/Services/Sms/LogSmsService.php new file mode 100644 index 0000000..8aca90f --- /dev/null +++ b/app/Services/Sms/LogSmsService.php @@ -0,0 +1,17 @@ + $id, 'to' => $to, 'message' => $message]); + + return SmsResult::success($id); + } +} diff --git a/app/Services/Sms/SemaphoreSmsService.php b/app/Services/Sms/SemaphoreSmsService.php new file mode 100644 index 0000000..cd7c6af --- /dev/null +++ b/app/Services/Sms/SemaphoreSmsService.php @@ -0,0 +1,51 @@ +timeout(10) + ->post($this->endpoint, [ + 'apikey' => $this->apiKey, + 'number' => $to, + 'message' => $message, + 'sendername' => $this->senderName, + ]); + } catch (ConnectionException $e) { + Log::warning('Semaphore SMS connection failed', ['to' => $to, 'error' => $e->getMessage()]); + + return SmsResult::failure('connection_failed'); + } + + if (! $response->successful()) { + Log::warning('Semaphore SMS HTTP error', [ + 'to' => $to, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return SmsResult::failure('provider_error_'.$response->status()); + } + + $payload = $response->json(); + $providerId = is_array($payload) && isset($payload[0]['message_id']) + ? (string) $payload[0]['message_id'] + : null; + + return SmsResult::success($providerId); + } +} diff --git a/app/Services/Sms/SmsResult.php b/app/Services/Sms/SmsResult.php new file mode 100644 index 0000000..4ca264d --- /dev/null +++ b/app/Services/Sms/SmsResult.php @@ -0,0 +1,22 @@ +withMiddleware(function (Middleware $middleware) { $middleware->statefulApi(); + $middleware->alias([ + 'role' => \App\Http\Middleware\EnsureUserHasRole::class, + ]); + $middleware->redirectGuestsTo(function (Request $request) { return $request->is('api/*') ? null : null; }); diff --git a/config/services.php b/config/services.php index 27a3617..403b888 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,19 @@ return [ ], ], + 'sms' => [ + 'driver' => env('SMS_DRIVER', 'log'), + ], + + 'semaphore' => [ + 'api_key' => env('SEMAPHORE_API_KEY'), + 'sender_name' => env('SEMAPHORE_SENDER_NAME', 'VERDE'), + ], + + 'otp' => [ + 'length' => (int) env('OTP_LENGTH', 6), + 'ttl_minutes' => (int) env('OTP_TTL_MINUTES', 10), + 'resend_cooldown_seconds' => (int) env('OTP_RESEND_COOLDOWN_SECONDS', 60), + ], + ]; diff --git a/database/migrations/2026_04_29_120000_create_otp_codes_table.php b/database/migrations/2026_04_29_120000_create_otp_codes_table.php new file mode 100644 index 0000000..98e9c7a --- /dev/null +++ b/database/migrations/2026_04_29_120000_create_otp_codes_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); + $table->string('channel', 16); + $table->string('destination', 191); + $table->string('purpose', 32); + $table->string('code_hash'); + $table->unsignedTinyInteger('attempts')->default(0); + $table->timestamp('expires_at'); + $table->timestamp('consumed_at')->nullable(); + $table->timestamps(); + + $table->index(['destination', 'purpose']); + $table->index(['user_id', 'purpose']); + }); + } + + public function down(): void + { + Schema::dropIfExists('otp_codes'); + } +}; diff --git a/database/seeders/AdminUserSeeder.php b/database/seeders/AdminUserSeeder.php new file mode 100644 index 0000000..ca44257 --- /dev/null +++ b/database/seeders/AdminUserSeeder.php @@ -0,0 +1,30 @@ + 'admin@verde.local'], + [ + 'phone' => '+639000000000', + 'password' => Hash::make('password'), + 'first_name' => 'Verde', + 'last_name' => 'Admin', + 'role' => User::ROLE_ADMIN, + 'status' => User::STATUS_ACTIVE, + 'email_verified_at' => now(), + 'phone_verified_at' => now(), + 'preferred_language' => 'en', + ], + ); + + $admin->syncRoles([User::ROLE_ADMIN]); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef..e44af9f 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,22 +2,15 @@ namespace Database\Seeders; -use App\Models\User; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { - /** - * Seed the application's database. - */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + $this->call([ + RoleSeeder::class, + AdminUserSeeder::class, ]); } } diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php new file mode 100644 index 0000000..9e424ea --- /dev/null +++ b/database/seeders/RoleSeeder.php @@ -0,0 +1,27 @@ +forgetCachedPermissions(); + + foreach ([ + User::ROLE_ADMIN, + User::ROLE_RESIDENT, + User::ROLE_DRIVER, + User::ROLE_HELPER, + User::ROLE_SCANNER, + User::ROLE_STORE_PARTNER, + ] as $name) { + Role::findOrCreate($name, 'web'); + } + } +} diff --git a/phpunit.xml b/phpunit.xml index 506b9a3..e45c1ef 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -22,12 +22,13 @@ - - + + + diff --git a/routes/api.php b/routes/api.php index 9c42c99..301366c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,7 +1,15 @@ name('api.v1.health'); -Route::middleware('auth:sanctum')->group(function () { - Route::get('/me', function (Request $request) { - return response()->json([ - 'success' => true, - 'data' => $request->user(), - 'message' => null, - 'errors' => null, - 'meta' => (object) [], - ]); - })->name('api.v1.me'); +Route::prefix('auth')->name('api.v1.auth.')->group(function () { + Route::middleware('throttle:10,1')->group(function () { + Route::post('/register', RegisterController::class)->name('register'); + Route::post('/login', LoginController::class)->name('login'); + Route::post('/forgot-password', ForgotPasswordController::class)->name('forgot-password'); + Route::post('/reset-password', ResetPasswordController::class)->name('reset-password'); + Route::post('/otp/verify', VerifyOtpController::class)->name('otp.verify'); + Route::post('/otp/resend', ResendOtpController::class)->name('otp.resend'); + }); + + Route::middleware('auth:sanctum')->group(function () { + Route::post('/logout', LogoutController::class)->name('logout'); + Route::post('/refresh', RefreshTokenController::class)->name('refresh'); + Route::get('/me', MeController::class)->name('me'); + }); +}); + +Route::middleware('auth:sanctum')->group(function () { + Route::get('/me', MeController::class)->name('api.v1.me'); }); diff --git a/tests/Feature/Api/V1/Auth/LoginTest.php b/tests/Feature/Api/V1/Auth/LoginTest.php new file mode 100644 index 0000000..02c5770 --- /dev/null +++ b/tests/Feature/Api/V1/Auth/LoginTest.php @@ -0,0 +1,104 @@ +seed(RoleSeeder::class); + } + + public function test_active_user_can_login_with_email(): void + { + $user = User::factory()->create([ + 'email' => 'login@example.com', + 'password' => Hash::make('Password123'), + 'status' => User::STATUS_ACTIVE, + ]); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => 'login@example.com', + 'password' => 'Password123', + 'device_name' => 'phpunit', + ]); + + $response->assertOk() + ->assertJsonPath('success', true) + ->assertJsonPath('data.user.email', 'login@example.com') + ->assertJsonStructure(['data' => ['user', 'token', 'token_type']]); + + $this->assertDatabaseHas('personal_access_tokens', [ + 'tokenable_id' => $user->id, + 'name' => 'phpunit', + ]); + } + + public function test_login_rejects_bad_password(): void + { + User::factory()->create([ + 'email' => 'login@example.com', + 'password' => Hash::make('Password123'), + 'status' => User::STATUS_ACTIVE, + ]); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => 'login@example.com', + 'password' => 'wrong', + ]); + + $response->assertStatus(401) + ->assertJsonPath('success', false); + } + + public function test_pending_user_cannot_login(): void + { + User::factory()->create([ + 'email' => 'pending@example.com', + 'password' => Hash::make('Password123'), + 'status' => User::STATUS_PENDING, + ]); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => 'pending@example.com', + 'password' => 'Password123', + ]); + + $response->assertStatus(403) + ->assertJsonPath('success', false); + } + + public function test_suspended_user_cannot_login(): void + { + User::factory()->create([ + 'email' => 'sus@example.com', + 'password' => Hash::make('Password123'), + 'status' => User::STATUS_SUSPENDED, + ]); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => 'sus@example.com', + 'password' => 'Password123', + ]); + + $response->assertStatus(403); + } + + public function test_login_requires_email_or_phone(): void + { + $response = $this->postJson('/api/v1/auth/login', [ + 'password' => 'Password123', + ]); + + $response->assertStatus(422); + } +} diff --git a/tests/Feature/Api/V1/Auth/LogoutRefreshMeTest.php b/tests/Feature/Api/V1/Auth/LogoutRefreshMeTest.php new file mode 100644 index 0000000..b9d035f --- /dev/null +++ b/tests/Feature/Api/V1/Auth/LogoutRefreshMeTest.php @@ -0,0 +1,64 @@ +seed(RoleSeeder::class); + } + + public function test_me_returns_authed_user(): void + { + $user = User::factory()->create(['status' => User::STATUS_ACTIVE]); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/auth/me'); + + $response->assertOk() + ->assertJsonPath('data.user.email', $user->email); + } + + public function test_me_rejects_unauthed(): void + { + $this->getJson('/api/v1/auth/me')->assertStatus(401); + } + + public function test_logout_revokes_token(): void + { + $user = User::factory()->create(['status' => User::STATUS_ACTIVE]); + $token = $user->createToken('phpunit'); + $tokenId = $token->accessToken->id; + + $response = $this->withHeader('Authorization', 'Bearer '.$token->plainTextToken) + ->postJson('/api/v1/auth/logout'); + + $response->assertOk(); + $this->assertDatabaseMissing('personal_access_tokens', ['id' => $tokenId]); + } + + public function test_refresh_issues_new_token_and_revokes_old(): void + { + $user = User::factory()->create(['status' => User::STATUS_ACTIVE]); + $token = $user->createToken('phpunit'); + $oldId = $token->accessToken->id; + + $response = $this->withHeader('Authorization', 'Bearer '.$token->plainTextToken) + ->postJson('/api/v1/auth/refresh'); + + $response->assertOk() + ->assertJsonStructure(['data' => ['token']]); + + $this->assertDatabaseMissing('personal_access_tokens', ['id' => $oldId]); + } +} diff --git a/tests/Feature/Api/V1/Auth/OtpTest.php b/tests/Feature/Api/V1/Auth/OtpTest.php new file mode 100644 index 0000000..a441372 --- /dev/null +++ b/tests/Feature/Api/V1/Auth/OtpTest.php @@ -0,0 +1,106 @@ +seed(RoleSeeder::class); + $this->fakeSms = new FakeSmsService(); + $this->app->instance(SmsService::class, $this->fakeSms); + } + + public function test_register_otp_can_be_verified_and_activates_user(): void + { + $user = User::factory()->create([ + 'phone' => '+639170000010', + 'status' => User::STATUS_PENDING, + 'phone_verified_at' => null, + ]); + + $issue = app(OtpService::class)->issue( + $user->phone, + OtpCode::PURPOSE_REGISTER, + $user, + ); + + $response = $this->postJson('/api/v1/auth/otp/verify', [ + 'phone' => $user->phone, + 'code' => $issue->plainCode, + 'purpose' => OtpCode::PURPOSE_REGISTER, + ]); + + $response->assertOk() + ->assertJsonPath('data.verified', true) + ->assertJsonPath('data.user.status', User::STATUS_ACTIVE); + + $this->assertNotNull($user->fresh()->phone_verified_at); + } + + public function test_invalid_otp_rejected(): void + { + $user = User::factory()->create(['phone' => '+639170000011']); + app(OtpService::class)->issue($user->phone, OtpCode::PURPOSE_REGISTER, $user); + + $response = $this->postJson('/api/v1/auth/otp/verify', [ + 'phone' => $user->phone, + 'code' => '000000', + 'purpose' => OtpCode::PURPOSE_REGISTER, + ]); + + $response->assertStatus(422) + ->assertJsonPath('success', false); + } + + public function test_otp_locks_after_max_attempts(): void + { + $user = User::factory()->create(['phone' => '+639170000012']); + app(OtpService::class)->issue($user->phone, OtpCode::PURPOSE_REGISTER, $user); + + for ($i = 0; $i < OtpCode::MAX_ATTEMPTS; $i++) { + $this->postJson('/api/v1/auth/otp/verify', [ + 'phone' => $user->phone, + 'code' => '999999', + 'purpose' => OtpCode::PURPOSE_REGISTER, + ]); + } + + $response = $this->postJson('/api/v1/auth/otp/verify', [ + 'phone' => $user->phone, + 'code' => '111111', + 'purpose' => OtpCode::PURPOSE_REGISTER, + ]); + + $response->assertStatus(422) + ->assertJsonPath('errors.code.0', 'exhausted'); + } + + public function test_resend_otp_throttled_within_cooldown(): void + { + $user = User::factory()->create(['phone' => '+639170000013']); + app(OtpService::class)->issue($user->phone, OtpCode::PURPOSE_REGISTER, $user); + + $response = $this->postJson('/api/v1/auth/otp/resend', [ + 'phone' => $user->phone, + 'purpose' => OtpCode::PURPOSE_REGISTER, + ]); + + $response->assertStatus(429) + ->assertJsonPath('success', false); + } +} diff --git a/tests/Feature/Api/V1/Auth/PasswordResetTest.php b/tests/Feature/Api/V1/Auth/PasswordResetTest.php new file mode 100644 index 0000000..f95ba7a --- /dev/null +++ b/tests/Feature/Api/V1/Auth/PasswordResetTest.php @@ -0,0 +1,97 @@ +seed(RoleSeeder::class); + $this->app->instance(SmsService::class, new FakeSmsService()); + } + + public function test_forgot_password_returns_ok_for_existing_phone(): void + { + $user = User::factory()->create(['phone' => '+639170000020']); + + $response = $this->postJson('/api/v1/auth/forgot-password', [ + 'phone' => $user->phone, + ]); + + $response->assertOk(); + $this->assertDatabaseHas('otp_codes', [ + 'destination' => $user->phone, + 'purpose' => OtpCode::PURPOSE_PASSWORD_RESET, + ]); + } + + public function test_forgot_password_does_not_leak_unknown_phone(): void + { + $response = $this->postJson('/api/v1/auth/forgot-password', [ + 'phone' => '+639170000099', + ]); + + $response->assertOk(); + $this->assertDatabaseMissing('otp_codes', [ + 'destination' => '+639170000099', + 'purpose' => OtpCode::PURPOSE_PASSWORD_RESET, + ]); + } + + public function test_reset_password_succeeds_with_valid_otp(): void + { + $user = User::factory()->create([ + 'phone' => '+639170000021', + 'password' => Hash::make('OldPassword1'), + ]); + $user->createToken('old-device'); + + $issue = app(OtpService::class)->issue( + $user->phone, + OtpCode::PURPOSE_PASSWORD_RESET, + $user, + ); + + $response = $this->postJson('/api/v1/auth/reset-password', [ + 'phone' => $user->phone, + 'code' => $issue->plainCode, + 'password' => 'NewPassword1', + 'password_confirmation' => 'NewPassword1', + ]); + + $response->assertOk(); + $this->assertTrue(Hash::check('NewPassword1', $user->fresh()->password)); + $this->assertDatabaseMissing('personal_access_tokens', [ + 'tokenable_id' => $user->id, + ]); + } + + public function test_reset_password_rejects_invalid_otp(): void + { + $user = User::factory()->create(['phone' => '+639170000022']); + app(OtpService::class)->issue($user->phone, OtpCode::PURPOSE_PASSWORD_RESET, $user); + + $response = $this->postJson('/api/v1/auth/reset-password', [ + 'phone' => $user->phone, + 'code' => '000000', + 'password' => 'NewPassword1', + 'password_confirmation' => 'NewPassword1', + ]); + + $response->assertStatus(422) + ->assertJsonPath('success', false); + } +} diff --git a/tests/Feature/Api/V1/Auth/RegisterTest.php b/tests/Feature/Api/V1/Auth/RegisterTest.php new file mode 100644 index 0000000..75aa5d5 --- /dev/null +++ b/tests/Feature/Api/V1/Auth/RegisterTest.php @@ -0,0 +1,114 @@ +seed(RoleSeeder::class); + $this->fakeSms = new FakeSmsService(); + $this->app->instance(SmsService::class, $this->fakeSms); + } + + public function test_resident_can_register(): void + { + $payload = [ + 'first_name' => 'Juan', + 'last_name' => 'Dela Cruz', + 'email' => 'juan@example.com', + 'phone' => '+639171234567', + 'password' => 'Password123', + 'password_confirmation' => 'Password123', + 'role' => User::ROLE_RESIDENT, + ]; + + $response = $this->postJson('/api/v1/auth/register', $payload); + + $response->assertCreated() + ->assertJsonPath('success', true) + ->assertJsonPath('data.user.email', 'juan@example.com') + ->assertJsonPath('data.user.role', User::ROLE_RESIDENT) + ->assertJsonPath('data.user.status', User::STATUS_PENDING) + ->assertJsonPath('data.otp_sent', true); + + $this->assertDatabaseHas('users', [ + 'email' => 'juan@example.com', + 'phone' => '+639171234567', + 'role' => User::ROLE_RESIDENT, + 'status' => User::STATUS_PENDING, + ]); + $this->assertDatabaseHas('otp_codes', [ + 'destination' => '+639171234567', + 'purpose' => OtpCode::PURPOSE_REGISTER, + ]); + $this->assertNotNull($this->fakeSms->lastMessageTo('+639171234567')); + + $user = User::where('email', 'juan@example.com')->first(); + $this->assertTrue($user->hasRole(User::ROLE_RESIDENT)); + } + + public function test_register_rejects_duplicate_email(): void + { + User::factory()->create(['email' => 'taken@example.com']); + + $response = $this->postJson('/api/v1/auth/register', [ + 'first_name' => 'X', + 'last_name' => 'Y', + 'email' => 'taken@example.com', + 'phone' => '+639170000001', + 'password' => 'Password123', + 'password_confirmation' => 'Password123', + 'role' => User::ROLE_RESIDENT, + ]); + + $response->assertStatus(422) + ->assertJsonPath('success', false) + ->assertJsonValidationErrors(['email']); + } + + public function test_register_rejects_admin_role_self_signup(): void + { + $response = $this->postJson('/api/v1/auth/register', [ + 'first_name' => 'Sneaky', + 'last_name' => 'Admin', + 'email' => 'sneaky@example.com', + 'phone' => '+639170000002', + 'password' => 'Password123', + 'password_confirmation' => 'Password123', + 'role' => 'admin', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['role']); + } + + public function test_register_requires_strong_password(): void + { + $response = $this->postJson('/api/v1/auth/register', [ + 'first_name' => 'X', + 'last_name' => 'Y', + 'email' => 'x@example.com', + 'phone' => '+639170000003', + 'password' => 'short', + 'password_confirmation' => 'short', + 'role' => User::ROLE_RESIDENT, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['password']); + } +} diff --git a/tests/Feature/Api/V1/Auth/RoleMiddlewareTest.php b/tests/Feature/Api/V1/Auth/RoleMiddlewareTest.php new file mode 100644 index 0000000..7c1a29f --- /dev/null +++ b/tests/Feature/Api/V1/Auth/RoleMiddlewareTest.php @@ -0,0 +1,51 @@ +seed(RoleSeeder::class); + + Route::middleware(['auth:sanctum', 'role:admin']) + ->get('/api/v1/_test/admin-only', fn () => response()->json(['ok' => true])); + } + + public function test_admin_can_access_role_protected_route(): void + { + $admin = User::factory()->create([ + 'role' => User::ROLE_ADMIN, + 'status' => User::STATUS_ACTIVE, + ]); + Sanctum::actingAs($admin); + + $this->getJson('/api/v1/_test/admin-only')->assertOk(); + } + + public function test_resident_blocked_from_admin_route(): void + { + $resident = User::factory()->create([ + 'role' => User::ROLE_RESIDENT, + 'status' => User::STATUS_ACTIVE, + ]); + Sanctum::actingAs($resident); + + $this->getJson('/api/v1/_test/admin-only')->assertStatus(403); + } + + public function test_unauthed_blocked_from_admin_route(): void + { + $this->getJson('/api/v1/_test/admin-only')->assertStatus(401); + } +} diff --git a/tests/Feature/Api/V1/HealthTest.php b/tests/Feature/Api/V1/HealthTest.php new file mode 100644 index 0000000..9915b1c --- /dev/null +++ b/tests/Feature/Api/V1/HealthTest.php @@ -0,0 +1,19 @@ +getJson('/api/v1/health'); + + $response->assertOk() + ->assertJsonPath('success', true) + ->assertJsonPath('data.service', config('app.name')) + ->assertJsonPath('data.version', 'v1') + ->assertJsonPath('data.database.status', 'ok'); + } +}