diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 20fbe2b..810887c 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -54,5 +54,7 @@ jobs: DB_PASSWORD_PROD: ${{ secrets.PROD_DB_PASSWORD }} REDIS_PASSWORD_PROD: ${{ secrets.PROD_REDIS_PASSWORD }} + JWT_SECRET_PROD: ${{ secrets.PROD_JWT_SECRET }} DB_PASSWORD_DEV: ${{ secrets.DEV_DB_PASSWORD }} - REDIS_PASSWORD_DEV: ${{ secrets.DEV_REDIS_PASSWORD }} \ No newline at end of file + REDIS_PASSWORD_DEV: ${{ secrets.DEV_REDIS_PASSWORD }} + JWT_SECRET_DEV: ${{ secrets.DEV_JWT_SECRET }} \ No newline at end of file diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php new file mode 100644 index 0000000..8c35174 --- /dev/null +++ b/app/Http/Controllers/AuthController.php @@ -0,0 +1,82 @@ +validate([ + 'name' => ['required','string','max:120'], + 'email' => ['required','email','max:190','unique:users,email'], + 'password' => ['required','string','min:8','confirmed'], + ]); + + $user = User::query()->create([ + 'name' => $data['name'], + 'email' => $data['email'], + 'password' => Hash::make($data['password']), + ]); + + $token = $jwt->issueToken($user); + + return response()->json([ + 'token' => $token, + 'token_type' => 'Bearer', + 'expires_in' => (int) config('jwt.ttl'), + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + ], + ], 201); + } + + public function login(Request $request, JwtService $jwt) + { + $data = $request->validate([ + 'email' => ['required','email'], + 'password' => ['required','string'], + ]); + + $user = User::query()->where('email', $data['email'])->first(); + + if (!$user || !Hash::check($data['password'], $user->password)) { + throw ValidationException::withMessages([ + 'email' => ['Invalid credentials.'], + ]); + } + + $token = $jwt->issueToken($user); + + return response()->json([ + 'token' => $token, + 'token_type' => 'Bearer', + 'expires_in' => (int) config('jwt.ttl'), + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + ], + ]); + } + + public function me(Request $request) + { + $user = $request->user(); + + return response()->json([ + 'user' => [ + 'id' => $user?->id, + 'name' => $user?->name, + 'email' => $user?->email, + ], + ]); + } +} \ No newline at end of file diff --git a/app/Http/Middleware/JwtAuth.php b/app/Http/Middleware/JwtAuth.php new file mode 100644 index 0000000..eb71065 --- /dev/null +++ b/app/Http/Middleware/JwtAuth.php @@ -0,0 +1,44 @@ +header('Authorization', ''); + + if (!str_starts_with($authHeader, 'Bearer ')) { + return response()->json(['message' => 'Missing Bearer token'], 401); + } + + $token = trim(substr($authHeader, 7)); + + try { + $claims = app(JwtService::class)->decode($token); + + $userId = $claims['sub'] ?? null; + if (!$userId) { + return response()->json(['message' => 'Invalid token'], 401); + } + + $user = User::query()->find($userId); + if (!$user) { + return response()->json(['message' => 'User not found'], 401); + } + + Auth::setUser($user); + $request->attributes->set('jwt_claims', $claims); + + return $next($request); + } catch (\Throwable $e) { + return response()->json(['message' => 'Invalid or expired token'], 401); + } + } +} \ No newline at end of file diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b7..04f4529 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -9,35 +9,12 @@ class User extends Authenticatable { - /** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory, Notifiable; - /** - * The attributes that are mass assignable. - * - * @var list - */ - protected $fillable = [ - 'name', - 'email', - 'password', - ]; + protected $fillable = ['name', 'email', 'password']; - /** - * The attributes that should be hidden for serialization. - * - * @var list - */ - protected $hidden = [ - 'password', - 'remember_token', - ]; + protected $hidden = ['password', 'remember_token']; - /** - * Get the attributes that should be cast. - * - * @return array - */ protected function casts(): array { return [ diff --git a/app/Services/JwtService.php b/app/Services/JwtService.php new file mode 100644 index 0000000..8f52ec2 --- /dev/null +++ b/app/Services/JwtService.php @@ -0,0 +1,44 @@ +timestamp; + $ttl = (int) config('jwt.ttl'); + + $payload = [ + 'iss' => config('jwt.issuer'), + 'aud' => config('jwt.audience'), + 'iat' => $now, + 'nbf' => $now, + 'exp' => $now + $ttl, + 'sub' => (string) $user->id, + 'email' => $user->email, + ]; + + return JWT::encode($payload, $this->secret(), 'HS256'); + } + + public function decode(string $token): array + { + $decoded = JWT::decode($token, new Key($this->secret(), 'HS256')); + return (array) $decoded; + } + + private function secret(): string + { + $secret = (string) config('jwt.secret'); + if ($secret === '') { + throw new \RuntimeException('JWT secret is not configured (JWT_SECRET).'); + } + return $secret; + } +} \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php index c3928c5..186e696 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'jwt' => JwtAuth::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index 44c6054..66b90d5 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "license": "MIT", "require": { "php": "^8.2", + "firebase/php-jwt": "^7.0", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1" }, diff --git a/composer.lock b/composer.lock index 58a0593..ae5cab9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c514d8f7b9fc5970bdd94287905ef584", + "content-hash": "1d3e782b8af4b1f8a0ce91cd12381a02", "packages": [ { "name": "brick/math", @@ -508,6 +508,69 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.0.2", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v7.0.2" + }, + "time": "2025-12-16T22:17:28+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", diff --git a/config/jwt.php b/config/jwt.php new file mode 100644 index 0000000..37909e7 --- /dev/null +++ b/config/jwt.php @@ -0,0 +1,8 @@ + env('JWT_SECRET'), + 'ttl' => (int) env('JWT_TTL_SECONDS', 3600), + 'issuer' => env('JWT_ISSUER', 'cloudshopt-user-service'), + 'audience' => env('JWT_AUDIENCE', 'cloudshopt'), +]; \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index b819a83..b04584c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,11 +1,12 @@ json([ - 'ok11' => true, + 'ok' => true, 'service' => config('app.name'), 'sha' => env('IMAGE_SHA', null), 'time' => now()->toISOString(), @@ -35,3 +36,10 @@ ], 500); } }); + +Route::prefix('auth')->group(function () { + Route::post('/register', [AuthController::class, 'register']); + Route::post('/login', [AuthController::class, 'login']); +}); + +Route::middleware('jwt')->get('/me', [AuthController::class, 'me']); \ No newline at end of file