Skip to content

Commit 5d8eacf

Browse files
authored
Merge pull request #83 from phenixphp/feature/user-authentication
Integration of framework v0.8.*
2 parents 396ba2f + 5c6e7b9 commit 5d8eacf

62 files changed

Lines changed: 4000 additions & 1299 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.dockerignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ coverage/
4242
*.pid.lock
4343

4444
.phpunit.result.cache
45-
.pest
4645
.php_cs.cache
4746

4847
dist/

.env.example

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,30 @@ DB_CONNECTION=mysql
99
DB_HOST=127.0.0.1
1010
DB_PORT=3306
1111
DB_DATABASE=phenix
12-
DB_USERNAME=phenix
13-
DB_PASSWORD=
12+
DB_USERNAME=root
13+
DB_PASSWORD=secret
1414

15-
LOG_CHANNEL=stream
15+
LOG_CHANNEL=file
1616

17-
QUEUE_DRIVER=parallel
17+
CACHE_STORE=redis
18+
RATE_LIMIT_STORE="${CACHE_STORE}"
19+
20+
QUEUE_DRIVER=redis
1821

1922
CORS_ORIGIN=
2023

2124
REDIS_HOST=127.0.0.1
2225
REDIS_PORT=6379
26+
REDIS_USERNAME=
2327
REDIS_PASSWORD=null
2428

25-
SESSION_DRIVER=local
29+
SESSION_DRIVER=redis
30+
31+
MAIL_MAILER=smtp
32+
MAIL_HOST=127.0.0.1
33+
MAIL_PORT=587
34+
MAIL_ENCRYPTION=tls
35+
MAIL_USERNAME=null
36+
MAIL_PASSWORD=null
37+
MAIL_FROM_ADDRESS=hello@example.com
38+
MAIL_FROM_NAME="Example"

.github/copilot-instructions.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,10 @@ XDEBUG_MODE=off php public/index.php # Direct server start (faster, no debuggin
2525

2626
### Testing
2727
```bash
28-
composer test # Pest tests (XDEBUG_MODE=off)
28+
composer test # PHPUnit tests (XDEBUG_MODE=off)
2929
composer test:coverage # With coverage reports
30-
composer test:parallel # Parallel execution
3130
```
32-
- **Test Framework**: Pest PHP with custom HTTP client helpers
31+
- **Test Framework**: PHPUnit with custom HTTP client helpers
3332
- **Test Structure**: `tests/Feature/` and `tests/Unit/` with shared `TestCase`
3433
- **HTTP Testing**: Uses Amp HTTP client with helper functions: `get()`, `post()`, etc.
3534

@@ -133,7 +132,6 @@ class MyController extends Controller
133132
- `config/app.php` - Service provider registration and app config
134133
- `vendor/phenixphp/framework/src/Queue/` - Queue implementation details
135134
- `vendor/phenixphp/framework/src/Tasks/QueuableTask.php` - Base task class
136-
- `tests/Pest.php` - HTTP testing helpers and setup
137135
- `bootstrap/app.php` - Application bootstrap via `AppBuilder`
138136

139137
## Common Pitfalls

.github/workflows/run-tests.yml

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,30 @@ on:
99
jobs:
1010
test:
1111
runs-on: ubuntu-latest
12+
services:
13+
mysql:
14+
image: mysql:8.0
15+
env:
16+
MYSQL_DATABASE: phenix
17+
MYSQL_USER: phenix
18+
MYSQL_PASSWORD: secret
19+
MYSQL_ROOT_PASSWORD: secret
20+
ports:
21+
- 3306:3306
22+
options: >-
23+
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -psecret --silent"
24+
--health-interval=10s
25+
--health-timeout=5s
26+
--health-retries=10
27+
redis:
28+
image: redis:7-alpine
29+
ports:
30+
- 6379:6379
31+
options: >-
32+
--health-cmd="redis-cli ping"
33+
--health-interval=10s
34+
--health-timeout=5s
35+
--health-retries=10
1236
1337
steps:
1438
- name: Checkout code
@@ -20,7 +44,7 @@ jobs:
2044
uses: shivammathur/setup-php@v2
2145
with:
2246
php-version: 8.2
23-
extensions: json, mbstring, pcntl, intl, fileinfo
47+
extensions: json, mbstring, pcntl, intl, fileinfo, sockets, mysqli, sqlite3
2448
coverage: xdebug
2549

2650
- name: Setup problem matchers
@@ -38,21 +62,22 @@ jobs:
3862
3963
- name: Analyze code statically with PHPStan
4064
run: |
41-
cp .env.example .env
42-
vendor/bin/phpstan --xdebug
65+
cp .env.example .env.testing
66+
XDEBUG_MODE=off vendor/bin/phpstan --xdebug
4367
4468
- name: Execute tests
4569
run: |
46-
cp .env.example .env
47-
vendor/bin/pest --coverage
70+
cp .env.example .env.testing
71+
php phenix key:generate .env.testing --force
72+
vendor/bin/phpunit
4873
4974
- name: Prepare paths for SonarQube analysis
5075
run: |
5176
sed -i "s|$GITHUB_WORKSPACE|/github/workspace|g" build/logs/clover.xml
5277
sed -i "s|$GITHUB_WORKSPACE|/github/workspace|g" build/report.junit.xml
5378
5479
- name: Run SonarQube analysis
55-
uses: sonarsource/sonarcloud-github-action@master
80+
uses: sonarsource/sonarqube-scan-action@master
5681
env:
5782
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5883
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ tests/coverage
1212
node_modules
1313
npm-debug.log
1414
package-lock.json
15-
package.json
15+
package.json
16+
*.sqlite*
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Constants;
6+
7+
enum OneTimePasswordScope: string
8+
{
9+
case LOGIN = 'login';
10+
11+
case RESET_PASSWORD = 'reset_password';
12+
13+
case VERIFY_EMAIL = 'verify_email';
14+
15+
case AUTHORIZE = 'authorize';
16+
17+
public static function toArray(): array
18+
{
19+
return array_column(self::cases(), 'value');
20+
}
21+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers\Auth;
6+
7+
use App\Constants\OneTimePasswordScope;
8+
use App\Models\User;
9+
use App\Models\UserOtp;
10+
use Egulias\EmailValidator\Validation\DNSCheckValidation;
11+
use Egulias\EmailValidator\Validation\NoRFCWarningsValidation;
12+
use Phenix\Http\Constants\HttpStatus;
13+
use Phenix\Http\Controller;
14+
use Phenix\Http\Request;
15+
use Phenix\Http\Response;
16+
use Phenix\Util\Date;
17+
use Phenix\Validation\Types\Email;
18+
use Phenix\Validation\Validator;
19+
20+
class ForgotPasswordController extends Controller
21+
{
22+
public function store(Request $request): Response
23+
{
24+
$validator = new Validator($request);
25+
$validator->setRules([
26+
'email' => Email::required()->validations(
27+
new DNSCheckValidation(),
28+
new NoRFCWarningsValidation()
29+
)->max(100),
30+
]);
31+
32+
if ($validator->fails()) {
33+
return response()->json([
34+
'errors' => $validator->failing(),
35+
], HttpStatus::UNPROCESSABLE_ENTITY);
36+
}
37+
38+
$user = User::query()
39+
->whereEqual('email', $request->body('email'))
40+
->whereNotNull('email_verified_at')
41+
->first();
42+
43+
if ($user !== null) {
44+
$otpCount = UserOtp::query()
45+
->whereEqual('user_id', $user->id)
46+
->whereEqual('scope', OneTimePasswordScope::RESET_PASSWORD->value)
47+
->whereGreaterThanOrEqual('created_at', Date::now()->subHour()->toDateTimeString())
48+
->count();
49+
50+
if ($otpCount < 5) {
51+
$user->sendOneTimePassword(OneTimePasswordScope::RESET_PASSWORD);
52+
}
53+
}
54+
55+
return response()->json([
56+
'message' => trans('auth.password_reset.sent'),
57+
], HttpStatus::OK);
58+
}
59+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers\Auth;
6+
7+
use App\Constants\OneTimePasswordScope;
8+
use App\Models\User;
9+
use App\Models\UserOtp;
10+
use Egulias\EmailValidator\Validation\DNSCheckValidation;
11+
use Egulias\EmailValidator\Validation\NoRFCWarningsValidation;
12+
use Phenix\Facades\Hash;
13+
use Phenix\Http\Constants\HttpStatus;
14+
use Phenix\Http\Controller;
15+
use Phenix\Http\Request;
16+
use Phenix\Http\Response;
17+
use Phenix\Util\Date;
18+
use Phenix\Validation\Types\Email;
19+
use Phenix\Validation\Types\Numeric;
20+
use Phenix\Validation\Types\Password;
21+
use Phenix\Validation\Validator;
22+
23+
class LoginController extends Controller
24+
{
25+
public function login(Request $request): Response
26+
{
27+
$validator = new Validator($request);
28+
$validator->setRules([
29+
'email' => Email::required()->validations(
30+
new DNSCheckValidation(),
31+
new NoRFCWarningsValidation()
32+
)->max(100)
33+
->exists('users', 'email', function ($query): void {
34+
$query->whereNotNull('email_verified_at');
35+
}),
36+
'password' => Password::required(),
37+
]);
38+
39+
if ($validator->fails()) {
40+
return response()->json([
41+
'errors' => $validator->failing(),
42+
], HttpStatus::UNPROCESSABLE_ENTITY);
43+
}
44+
45+
$user = User::query()->whereEqual('email', $request->body('email'))->first();
46+
$response = response()->json([
47+
'message' => trans('auth.otp.login.sent'),
48+
]);
49+
50+
if (! Hash::verify($user->password, (string) $request->body('password'))) {
51+
$response = response()->json([
52+
'message' => trans('auth.login.invalid_credentials'),
53+
], HttpStatus::UNAUTHORIZED);
54+
} else {
55+
$otpCount = UserOtp::query()
56+
->whereEqual('user_id', $user->id)
57+
->whereEqual('scope', OneTimePasswordScope::LOGIN->value)
58+
->whereGreaterThanOrEqual('created_at', Date::now()->subHour()->toDateTimeString())
59+
->count();
60+
61+
if ($otpCount >= 5) {
62+
$response = response()->json([
63+
'message' => trans('auth.otp.limit_exceeded'),
64+
], HttpStatus::TOO_MANY_REQUESTS);
65+
} else {
66+
$user->sendOneTimePassword(OneTimePasswordScope::LOGIN);
67+
}
68+
}
69+
70+
return $response;
71+
}
72+
73+
public function authorize(Request $request): Response
74+
{
75+
$validator = new Validator($request);
76+
$validator->setRules([
77+
'email' => Email::required()->validations(
78+
new DNSCheckValidation(),
79+
new NoRFCWarningsValidation()
80+
)->max(100)
81+
->exists('users', 'email', function ($query): void {
82+
$query->whereNotNull('email_verified_at');
83+
}),
84+
'otp' => Numeric::required()->digits(6),
85+
]);
86+
87+
if ($validator->fails()) {
88+
return response()->json([
89+
'errors' => $validator->failing(),
90+
], HttpStatus::UNPROCESSABLE_ENTITY);
91+
}
92+
93+
$user = User::query()->whereEqual('email', $request->body('email'))->first();
94+
95+
$otp = UserOtp::query()
96+
->whereEqual('user_id', $user->id)
97+
->whereEqual('scope', OneTimePasswordScope::LOGIN->value)
98+
->whereEqual('code', hash('sha256', (string) $request->body('otp')))
99+
->whereNull('used_at')
100+
->whereGreaterThanOrEqual('expires_at', Date::now()->toDateTimeString())
101+
->first();
102+
103+
if (! $otp) {
104+
return response()->json([
105+
'message' => trans('auth.otp.invalid'),
106+
], HttpStatus::NOT_FOUND);
107+
}
108+
109+
$otp->usedAt = Date::now();
110+
$otp->save();
111+
112+
$token = $user->createToken('auth_token');
113+
114+
return response()->json([
115+
'access_token' => $token->toString(),
116+
'expires_at' => $token->expiresAt()->toDateTimeString(),
117+
'token_type' => 'Bearer',
118+
]);
119+
}
120+
121+
public function logout(Request $request): Response
122+
{
123+
/** @var User|null $user */
124+
$user = $request->user();
125+
126+
$user?->currentAccessToken()?->delete();
127+
128+
return response()->json([
129+
'message' => trans('auth.logout.success'),
130+
], HttpStatus::OK);
131+
}
132+
}

0 commit comments

Comments
 (0)