From 0ddd54dc66376ce836b97d5b30a54ff4e63eea34 Mon Sep 17 00:00:00 2001 From: soheil khaledabadi Date: Fri, 5 Jun 2026 19:19:03 +0330 Subject: [PATCH] feat(auth): implement authentication endpoints with registration and login functionality --- app/Http/Controllers/Api/AuthController.php | 156 +++++++++ app/Http/Requests/Auth/LoginRequest.php | 35 ++ app/Http/Requests/Auth/RegisterRequest.php | 44 +++ app/Http/Resources/UserResource.php | 38 +++ app/Models/User.php | 5 +- app/OpenApi.php | 14 + bootstrap/app.php | 1 + config/auth.php | 4 + config/l5-swagger.php | 321 ++++++++++++++++++ config/sanctum.php | 87 +++++ database/factories/UserFactory.php | 5 +- ...55_create_personal_access_tokens_table.php | 33 ++ ..._05_152100_add_username_to_users_table.php | 38 +++ .../views/vendor/l5-swagger/index.blade.php | 174 ++++++++++ routes/api.php | 9 + 15 files changed, 961 insertions(+), 3 deletions(-) create mode 100644 app/Http/Controllers/Api/AuthController.php create mode 100644 app/Http/Requests/Auth/LoginRequest.php create mode 100644 app/Http/Requests/Auth/RegisterRequest.php create mode 100644 app/Http/Resources/UserResource.php create mode 100644 app/OpenApi.php create mode 100644 config/l5-swagger.php create mode 100644 config/sanctum.php create mode 100644 database/migrations/2026_06_05_151955_create_personal_access_tokens_table.php create mode 100644 database/migrations/2026_06_05_152100_add_username_to_users_table.php create mode 100644 resources/views/vendor/l5-swagger/index.blade.php create mode 100644 routes/api.php diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php new file mode 100644 index 0000000..65e5b70 --- /dev/null +++ b/app/Http/Controllers/Api/AuthController.php @@ -0,0 +1,156 @@ +create([ + 'name' => $request->validated('name'), + 'username' => $this->generateUsername($request->validated('email')), + 'mobile' => $request->validated('mobile'), + 'email' => $request->validated('email'), + 'password' => $request->validated('password'), + ]); + + $token = $user->createToken('auth-token')->plainTextToken; + + return response()->json([ + 'message' => 'ثبت‌نام با موفقیت انجام شد.', + 'data' => [ + 'user' => new UserResource($user), + 'token' => $token, + ], + ], 201); + } + + #[OA\Post( + path: '/auth/login', + description: 'Login with email or username', + summary: 'Login', + tags: ['Auth'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['login', 'password'], + properties: [ + new OA\Property(property: 'login', description: 'Email or username', type: 'string', example: 'ali@example.com'), + new OA\Property(property: 'password', type: 'string', format: 'password', example: 'password123'), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Logged in successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'ورود با موفقیت انجام شد.'), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'user', ref: '#/components/schemas/User'), + new OA\Property(property: 'token', type: 'string'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 422, description: 'Invalid credentials'), + ] + )] + public function login(LoginRequest $request): JsonResponse + { + $login = $request->validated('login'); + + $user = User::query() + ->where(function ($query) use ($login): void { + $query->where('email', $login) + ->orWhere('username', $login); + }) + ->first(); + + if (! $user || ! Hash::check($request->validated('password'), $user->password)) { + return response()->json([ + 'message' => 'اطلاعات ورود نادرست است.', + 'errors' => [ + 'login' => ['ایمیل یا نام کاربری یا رمز عبور اشتباه است.'], + ], + ], 422); + } + + $token = $user->createToken('auth-token')->plainTextToken; + + return response()->json([ + 'message' => 'ورود با موفقیت انجام شد.', + 'data' => [ + 'user' => new UserResource($user), + 'token' => $token, + ], + ]); + } + + private function generateUsername(string $email): string + { + $base = Str::slug(Str::before($email, '@'), '') ?: 'user'; + $username = $base; + $counter = 1; + + while (User::query()->where('username', $username)->exists()) { + $username = $base.$counter; + $counter++; + } + + return $username; + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 0000000..45c03e5 --- /dev/null +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,35 @@ + + */ + public function rules(): array + { + return [ + 'login' => ['required', 'string'], + 'password' => ['required', 'string'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'login.required' => 'ایمیل یا نام کاربری الزامی است.', + 'password.required' => 'رمز عبور الزامی است.', + ]; + } +} diff --git a/app/Http/Requests/Auth/RegisterRequest.php b/app/Http/Requests/Auth/RegisterRequest.php new file mode 100644 index 0000000..f808e55 --- /dev/null +++ b/app/Http/Requests/Auth/RegisterRequest.php @@ -0,0 +1,44 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'mobile' => ['required', 'string', 'regex:/^09\d{9}$/', 'unique:users,mobile'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', 'string', 'min:8'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'name.required' => 'نام و نام خانوادگی الزامی است.', + 'mobile.required' => 'شماره موبایل الزامی است.', + 'mobile.regex' => 'فرمت شماره موبایل صحیح نیست.', + 'mobile.unique' => 'این شماره موبایل قبلاً ثبت شده است.', + 'email.required' => 'ایمیل الزامی است.', + 'email.email' => 'فرمت ایمیل صحیح نیست.', + 'email.unique' => 'این ایمیل قبلاً ثبت شده است.', + 'password.required' => 'رمز عبور الزامی است.', + 'password.min' => 'رمز عبور باید حداقل ۸ کاراکتر باشد.', + ]; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php new file mode 100644 index 0000000..e59cf0e --- /dev/null +++ b/app/Http/Resources/UserResource.php @@ -0,0 +1,38 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'username' => $this->username, + 'email' => $this->email, + 'mobile' => $this->mobile, + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 85bbf92..c2145f7 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -12,13 +12,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; -#[Fillable(['name', 'email', 'mobile', 'password'])] +#[Fillable(['name', 'username', 'email', 'mobile', 'password'])] #[Hidden(['password', 'remember_token'])] class User extends Authenticatable implements FilamentUser { /** @use HasFactory */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable; /** * @return BelongsToMany diff --git a/app/OpenApi.php b/app/OpenApi.php new file mode 100644 index 0000000..4a664a9 --- /dev/null +++ b/app/OpenApi.php @@ -0,0 +1,14 @@ +withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/config/auth.php b/config/auth.php index d7568ff..0375bb3 100644 --- a/config/auth.php +++ b/config/auth.php @@ -42,6 +42,10 @@ return [ 'driver' => 'session', 'provider' => 'users', ], + 'sanctum' => [ + 'driver' => 'sanctum', + 'provider' => 'users', + ], ], /* diff --git a/config/l5-swagger.php b/config/l5-swagger.php new file mode 100644 index 0000000..cbd98a0 --- /dev/null +++ b/config/l5-swagger.php @@ -0,0 +1,321 @@ + 'default', + 'documentations' => [ + 'default' => [ + 'api' => [ + 'title' => 'Hoshpoint API', + ], + + 'routes' => [ + /* + * Route for accessing api documentation interface + */ + 'api' => 'api/documentation', + ], + 'paths' => [ + /* + * Edit to include full URL in ui for assets + */ + 'use_absolute_path' => env('L5_SWAGGER_USE_ABSOLUTE_PATH', true), + + /* + * Edit to set path where swagger ui assets should be stored + */ + 'swagger_ui_assets_path' => env('L5_SWAGGER_UI_ASSETS_PATH', 'vendor/swagger-api/swagger-ui/dist/'), + + /* + * File name of the generated json documentation file + */ + 'docs_json' => 'api-docs.json', + + /* + * File name of the generated YAML documentation file + */ + 'docs_yaml' => 'api-docs.yaml', + + /* + * Set this to `json` or `yaml` to determine which documentation file to use in UI + */ + 'format_to_use_for_docs' => env('L5_FORMAT_TO_USE_FOR_DOCS', 'json'), + + /* + * Absolute paths to directory containing the swagger annotations are stored. + */ + 'annotations' => [ + base_path('app'), + ], + ], + ], + ], + 'defaults' => [ + 'routes' => [ + /* + * Route for accessing parsed swagger annotations. + */ + 'docs' => 'docs', + + /* + * Route for Oauth2 authentication callback. + */ + 'oauth2_callback' => 'api/oauth2-callback', + + /* + * Middleware allows to prevent unexpected access to API documentation + */ + 'middleware' => [ + 'api' => [], + 'asset' => [], + 'docs' => [], + 'oauth2_callback' => [], + ], + + /* + * Route Group options + */ + 'group_options' => [], + ], + + 'paths' => [ + /* + * Absolute path to location where parsed annotations will be stored + */ + 'docs' => storage_path('api-docs'), + + /* + * Absolute path to directory where to export views + */ + 'views' => base_path('resources/views/vendor/l5-swagger'), + + /* + * Edit to set the api's base path + */ + 'base' => env('L5_SWAGGER_BASE_PATH', null), + + /* + * Absolute path to directories that should be excluded from scanning + * @deprecated Please use `scanOptions.exclude` + * `scanOptions.exclude` overwrites this + */ + 'excludes' => [], + ], + + 'scanOptions' => [ + /** + * Configuration for default processors. Allows to pass processors configuration to swagger-php. + * + * @link https://zircote.github.io/swagger-php/reference/processors.html + */ + 'default_processors_configuration' => [ + /** Example */ + /** + * 'operationId.hash' => true, + * 'pathFilter' => [ + * 'tags' => [ + * '/pets/', + * '/store/', + * ], + * ],. + */ + ], + + /** + * analyser: defaults to \OpenApi\StaticAnalyser . + * + * @see scan + */ + 'analyser' => null, + + /** + * analysis: defaults to a new \OpenApi\Analysis . + * + * @see scan + */ + 'analysis' => null, + + /** + * Custom query path processors classes. + * + * @link https://github.com/zircote/swagger-php/tree/master/Examples/processors/schema-query-parameter + * @see scan + */ + 'processors' => [ + // new \App\SwaggerProcessors\SchemaQueryParameter(), + ], + + /** + * pattern: string $pattern File pattern(s) to scan (default: *.php) . + * + * @see scan + */ + 'pattern' => null, + + /* + * Absolute path to directories that should be excluded from scanning + * @note This option overwrites `paths.excludes` + * @see \OpenApi\scan + */ + 'exclude' => [], + + /* + * Allows to generate specs either for OpenAPI 3.0.0 or OpenAPI 3.1.0. + * By default the spec will be in version 3.0.0 + */ + 'open_api_spec_version' => env('L5_SWAGGER_OPEN_API_SPEC_VERSION', Generator::OPEN_API_DEFAULT_SPEC_VERSION), + ], + + /* + * API security definitions. Will be generated into documentation file. + */ + 'securityDefinitions' => [ + 'securitySchemes' => [ + /* + * Examples of Security schemes + */ + /* + 'api_key_security_example' => [ // Unique name of security + 'type' => 'apiKey', // The type of the security scheme. Valid values are "basic", "apiKey" or "oauth2". + 'description' => 'A short description for security scheme', + 'name' => 'api_key', // The name of the header or query parameter to be used. + 'in' => 'header', // The location of the API key. Valid values are "query" or "header". + ], + 'oauth2_security_example' => [ // Unique name of security + 'type' => 'oauth2', // The type of the security scheme. Valid values are "basic", "apiKey" or "oauth2". + 'description' => 'A short description for oauth2 security scheme.', + 'flow' => 'implicit', // The flow used by the OAuth2 security scheme. Valid values are "implicit", "password", "application" or "accessCode". + 'authorizationUrl' => 'http://example.com/auth', // The authorization URL to be used for (implicit/accessCode) + //'tokenUrl' => 'http://example.com/auth' // The authorization URL to be used for (password/application/accessCode) + 'scopes' => [ + 'read:projects' => 'read your projects', + 'write:projects' => 'modify projects in your account', + ] + ], + */ + + /* Open API 3.0 support + 'passport' => [ // Unique name of security + 'type' => 'oauth2', // The type of the security scheme. Valid values are "basic", "apiKey" or "oauth2". + 'description' => 'Laravel passport oauth2 security.', + 'in' => 'header', + 'scheme' => 'https', + 'flows' => [ + "password" => [ + "authorizationUrl" => config('app.url') . '/oauth/authorize', + "tokenUrl" => config('app.url') . '/oauth/token', + "refreshUrl" => config('app.url') . '/token/refresh', + "scopes" => [] + ], + ], + ], + 'sanctum' => [ // Unique name of security + 'type' => 'apiKey', // Valid values are "basic", "apiKey" or "oauth2". + 'description' => 'Enter token in format (Bearer )', + 'name' => 'Authorization', // The name of the header or query parameter to be used. + 'in' => 'header', // The location of the API key. Valid values are "query" or "header". + ], + */ + ], + 'security' => [ + /* + * Examples of Securities + */ + [ + /* + 'oauth2_security_example' => [ + 'read', + 'write' + ], + + 'passport' => [] + */ + ], + ], + ], + + /* + * Set this to `true` in development mode so that docs would be regenerated on each request + * Set this to `false` to disable swagger generation on production + */ + 'generate_always' => env('L5_SWAGGER_GENERATE_ALWAYS', false), + + /* + * Set this to `true` to generate a copy of documentation in yaml format + */ + 'generate_yaml_copy' => env('L5_SWAGGER_GENERATE_YAML_COPY', false), + + /* + * Edit to trust the proxy's ip address - needed for AWS Load Balancer + * string[] + */ + 'proxy' => false, + + /* + * Configs plugin allows to fetch external configs instead of passing them to SwaggerUIBundle. + * See more at: https://github.com/swagger-api/swagger-ui#configs-plugin + */ + 'additional_config_url' => null, + + /* + * Apply a sort to the operation list of each API. It can be 'alpha' (sort by paths alphanumerically), + * 'method' (sort by HTTP method). + * Default is the order returned by the server unchanged. + */ + 'operations_sort' => env('L5_SWAGGER_OPERATIONS_SORT', null), + + /* + * Pass the validatorUrl parameter to SwaggerUi init on the JS side. + * A null value here disables validation. + */ + 'validator_url' => null, + + /* + * Swagger UI configuration parameters + */ + 'ui' => [ + 'display' => [ + 'dark_mode' => env('L5_SWAGGER_UI_DARK_MODE', false), + /* + * Controls the default expansion setting for the operations and tags. It can be : + * 'list' (expands only the tags), + * 'full' (expands the tags and operations), + * 'none' (expands nothing). + */ + 'doc_expansion' => env('L5_SWAGGER_UI_DOC_EXPANSION', 'none'), + + /** + * If set, enables filtering. The top bar will show an edit box that + * you can use to filter the tagged operations that are shown. Can be + * Boolean to enable or disable, or a string, in which case filtering + * will be enabled using that string as the filter expression. Filtering + * is case-sensitive matching the filter expression anywhere inside + * the tag. + */ + 'filter' => env('L5_SWAGGER_UI_FILTERS', true), // true | false + ], + + 'authorization' => [ + /* + * If set to true, it persists authorization data, and it would not be lost on browser close/refresh + */ + 'persist_authorization' => env('L5_SWAGGER_UI_PERSIST_AUTHORIZATION', false), + + 'oauth2' => [ + /* + * If set to true, adds PKCE to AuthorizationCodeGrant flow + */ + 'use_pkce_with_authorization_code_grant' => false, + ], + ], + ], + /* + * Constants which can be used in annotations + */ + 'constants' => [ + 'L5_SWAGGER_CONST_HOST' => env('L5_SWAGGER_CONST_HOST', 'http://my-default-host.com'), + ], + ], +]; diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..cde73cf --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index fe1fbd8..251cf66 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -24,9 +24,12 @@ class UserFactory extends Factory */ public function definition(): array { + $email = fake()->unique()->safeEmail(); + return [ 'name' => fake()->name(), - 'email' => fake()->unique()->safeEmail(), + 'username' => fake()->unique()->userName(), + 'email' => $email, 'mobile' => fake()->unique()->numerify('09#########'), 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), diff --git a/database/migrations/2026_06_05_151955_create_personal_access_tokens_table.php b/database/migrations/2026_06_05_151955_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2026_06_05_151955_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_06_05_152100_add_username_to_users_table.php b/database/migrations/2026_06_05_152100_add_username_to_users_table.php new file mode 100644 index 0000000..4fdac8a --- /dev/null +++ b/database/migrations/2026_06_05_152100_add_username_to_users_table.php @@ -0,0 +1,38 @@ +string('username')->nullable()->unique()->after('name'); + }); + + foreach (DB::table('users')->orderBy('id')->get() as $user) { + $base = Str::slug(Str::before($user->email, '@'), '') ?: 'user'; + $username = $base; + $counter = 1; + + while (DB::table('users')->where('username', $username)->where('id', '!=', $user->id)->exists()) { + $username = $base.$counter; + $counter++; + } + + DB::table('users')->where('id', $user->id)->update(['username' => $username]); + } + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropUnique(['username']); + $table->dropColumn('username'); + }); + } +}; diff --git a/resources/views/vendor/l5-swagger/index.blade.php b/resources/views/vendor/l5-swagger/index.blade.php new file mode 100644 index 0000000..4f57040 --- /dev/null +++ b/resources/views/vendor/l5-swagger/index.blade.php @@ -0,0 +1,174 @@ + + + + + {{ $documentationTitle }} + + + + + @if(config('l5-swagger.defaults.ui.display.dark_mode')) + + @endif + + + +
+ + + + + + diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..e995a77 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,9 @@ +group(function (): void { + Route::post('register', [AuthController::class, 'register']); + Route::post('login', [AuthController::class, 'login']); +});