StreakFit — API Laravel 13 OSDD + Octane/FrankenPHP
Build & Deploy / build (push) Successful in 18s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 03:00:17 +02:00
co-authored by Claude Opus 4.8
commit af32669931
300 changed files with 21235 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"name": "functional/users",
"description": "",
"type": "layer",
"version": "1.0.0",
"require": {
"xefi/laravel-osdd": "*"
},
"autoload": {
"psr-4": {
"Functional\\Users\\": "src/",
"Functional\\Users\\Database\\Seeders\\": "database/seeders/",
"Functional\\Users\\Database\\Factories\\": "database/factories/"
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"extra": {
"laravel": {
"providers": [
"Functional\\Users\\Providers\\UsersServiceProvider"
]
}
}
}
@@ -0,0 +1,24 @@
<?php
namespace Functional\Users\Database\Factories;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class UserFactory extends Factory
{
protected $model = User::class;
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => Hash::make('password'),
'remember_token' => Str::random(10),
];
}
}
@@ -0,0 +1,33 @@
<?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::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
// Profil léger : locale/unités d'affichage, fuseau horaire, avatar média (RustFS/S3).
$table->string('locale')->default('fr');
$table->string('units')->default('metric'); // cast PHP Units (metric|imperial), pas d'enum SQL
$table->string('timezone')->nullable();
$table->string('avatar_path')->nullable();
// Identifiant du compte côté fournisseur OIDC (pocket-id) : nullable tant que le login est mot de passe uniquement.
$table->string('oidc_sub')->nullable()->unique();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('users');
}
};
@@ -0,0 +1,14 @@
<?php
namespace Functional\Users\Database\Seeders;
use Functional\Users\Models\User;
use Illuminate\Database\Seeder;
class UsersSeeder extends Seeder
{
public function run(): void
{
User::factory()->count(10)->create();
}
}
+7
View File
@@ -0,0 +1,7 @@
<?php
use Functional\Users\Http\Controllers\AuthController;
use Illuminate\Support\Facades\Route;
// Le préfixe api/ est ajouté par UsersServiceProvider::withRouting() : GET /api/me.
Route::get('me', [AuthController::class, 'me'])->middleware('auth:sanctum');
+6
View File
@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Broadcast;
// Define broadcast channels here, e.g.:
// Broadcast::channel('App.Models.User.{id}', fn($user, $id) => (int) $user->id === (int) $id);
+6
View File
@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Artisan;
// Define artisan commands here, e.g.:
// Artisan::command('layer:hello', fn() => info('Hello from this layer.'));
+16
View File
@@ -0,0 +1,16 @@
<?php
use Functional\Users\Http\Controllers\AuthController;
use Functional\Users\Http\Controllers\OidcController;
use Illuminate\Support\Facades\Route;
// Auth 100% back par cookie de session Sanctum SPA : inscription, login/logout par mot de passe,
// et login OIDC piloté par le back (pocket-id). Le front ne fait que suivre ces routes.
Route::post('register', [AuthController::class, 'register']);
Route::post('login', [AuthController::class, 'login']);
Route::post('logout', [AuthController::class, 'logout'])->middleware('auth');
Route::prefix('auth/oidc')->name('oidc.')->group(function (): void {
Route::get('redirect', [OidcController::class, 'redirect'])->name('redirect');
Route::get('callback', [OidcController::class, 'callback'])->name('callback');
});
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Functional\Users\Enums;
/**
* Système d'unités utilisé pour l'affichage des poids/distances de l'utilisateur.
* Stocké en base comme string ("no-db-enums") : le cast Eloquent fait le lien avec ce PHP enum.
*/
enum Units: string
{
case Metric = 'metric';
case Imperial = 'imperial';
/** Libellé court, utilisé le temps qu'aucune traduction dédiée ne soit nécessaire. */
public function label(): string
{
return match ($this) {
self::Metric => 'kg / cm',
self::Imperial => 'lb / in',
};
}
}
@@ -0,0 +1,24 @@
<?php
namespace Functional\Users\Exceptions;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* Levée quand l'échange du code d'autorisation contre un token, ou la récupération des claims
* userinfo, échoue auprès du fournisseur OIDC (pocket-id).
*/
class OidcAuthenticationFailedException extends Exception
{
/**
* Render the exception as an HTTP response.
*/
public function render(Request $request): Response
{
return response([
'message' => 'Échec de l\'authentification auprès du fournisseur OIDC.',
], Response::HTTP_BAD_GATEWAY);
}
}
@@ -0,0 +1,24 @@
<?php
namespace Functional\Users\Exceptions;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* Levée quand le `state` renvoyé par pocket-id ne correspond pas à celui stocké en session lors
* de la redirection : session expirée ou tentative de CSRF sur le callback OIDC.
*/
class OidcStateMismatchException extends Exception
{
/**
* Render the exception as an HTTP response.
*/
public function render(Request $request): Response
{
return response([
'message' => 'Authentification OIDC invalide : état de session non reconnu.',
], Response::HTTP_BAD_REQUEST);
}
}
@@ -0,0 +1,102 @@
<?php
namespace Functional\Users\Http\Controllers;
use Functional\Streaks\Models\Streak;
use Functional\Users\Http\Requests\LoginRequest;
use Functional\Users\Http\Requests\RegisterRequest;
use Functional\Users\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
/**
* Auth par cookie de session Sanctum SPA (inscription + login mot de passe). Le front n'a aucune
* logique d'auth : il POST ces routes et affiche ce qui revient.
*/
class AuthController
{
/**
* Inscription par mot de passe : crée le user, son streak vide, ouvre la session.
*/
public function register(RegisterRequest $request): JsonResponse
{
$user = User::create([
'name' => $request->validated('name'),
'email' => $request->validated('email'),
'password' => $request->validated('password'),
]);
Streak::firstOrCreate(['user_id' => $user->id]);
Auth::login($user);
$request->session()->regenerate();
return response()->json(['user' => $user], Response::HTTP_CREATED);
}
/**
* Login par mot de passe : la validation des identifiants vit dans LoginRequest::authenticate().
*/
public function login(LoginRequest $request): JsonResponse
{
$request->authenticate();
return response()->json(['user' => $request->user()]);
}
/**
* Ferme la session et invalide le cookie ; nécessite d'être authentifié (middleware `auth`).
*/
public function logout(Request $request): Response
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->noContent();
}
/**
* Vue "prête à afficher" du profil courant + résumé du streak, consommée par /api/me.
*/
public function me(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->user();
$streak = Streak::firstOrCreate(['user_id' => $user->id]);
return response()->json([
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'locale' => $user->locale,
'units' => $user->units,
'timezone' => $user->timezone,
'avatar_url' => $this->avatarUrl($user->avatar_path),
],
'streak' => [
'current' => $streak->current_count,
'longest' => $streak->longest_count,
'freezes' => $streak->freezes_available,
],
]);
}
/**
* Résout l'URL publique d'un média stocké sur RustFS/S3 à partir de son chemin relatif.
*/
private function avatarUrl(?string $avatarPath): ?string
{
if (! $avatarPath) {
return null;
}
return rtrim((string) config('services.media.base_url'), '/') . '/' . ltrim($avatarPath, '/');
}
}
@@ -0,0 +1,211 @@
<?php
namespace Functional\Users\Http\Controllers;
use Functional\Streaks\Models\Streak;
use Functional\Users\Exceptions\OidcAuthenticationFailedException;
use Functional\Users\Exceptions\OidcStateMismatchException;
use Functional\Users\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
/**
* Login OIDC (Authorization Code + PKCE S256) contre pocket-id, piloté 100% back : le front ne
* fait que suivre la redirection vers /auth/oidc/redirect et atterrir sur APP_FRONT_URL au retour.
*/
class OidcController
{
/**
* Redirige vers l'écran d'autorisation pocket-id. State + code_verifier PKCE en session.
*/
public function redirect(Request $request): RedirectResponse
{
$discovery = $this->discoverProvider();
$state = Str::random(40);
$codeVerifier = $this->generateCodeVerifier();
$request->session()->put('oidc_state', $state);
$request->session()->put('oidc_code_verifier', $codeVerifier);
$query = http_build_query([
'response_type' => 'code',
'client_id' => config('services.oidc.client_id'),
'redirect_uri' => config('services.oidc.redirect_uri'),
'scope' => 'openid profile email',
'state' => $state,
'code_challenge' => $this->codeChallenge($codeVerifier),
'code_challenge_method' => 'S256',
]);
return redirect()->away("{$discovery['authorization_endpoint']}?{$query}");
}
/**
* Échange le code contre un token, récupère les claims userinfo, lie/crée le user, ouvre la
* session, puis renvoie vers le front aucune logique d'auth n'est jamais exposée à celui-ci.
*/
public function callback(Request $request): RedirectResponse
{
$state = $request->query('state');
$expectedState = $request->session()->pull('oidc_state');
$codeVerifier = $request->session()->pull('oidc_code_verifier');
if (! is_string($state) || ! is_string($expectedState) || ! hash_equals($expectedState, $state)) {
throw new OidcStateMismatchException();
}
if (! is_string($codeVerifier)) {
throw new OidcStateMismatchException();
}
$code = $request->query('code');
if (! is_string($code)) {
throw new OidcAuthenticationFailedException();
}
$claims = $this->fetchUserinfoClaims($code, $codeVerifier);
$user = $this->upsertUserFromClaims($claims);
Auth::login($user);
$request->session()->regenerate();
return redirect()->away((string) config('app.front_url'));
}
/**
* Échange le code d'autorisation contre un access token puis récupère les claims userinfo.
*
* @return array<string, mixed>
*/
private function fetchUserinfoClaims(string $code, string $codeVerifier): array
{
$discovery = $this->discoverProvider();
$tokenResponse = Http::asForm()->post($discovery['token_endpoint'], [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => config('services.oidc.redirect_uri'),
'client_id' => config('services.oidc.client_id'),
'client_secret' => config('services.oidc.client_secret'),
'code_verifier' => $codeVerifier,
]);
if ($tokenResponse->failed()) {
throw new OidcAuthenticationFailedException();
}
$accessToken = $tokenResponse->json('access_token');
if (! is_string($accessToken)) {
throw new OidcAuthenticationFailedException();
}
$userinfoResponse = Http::withToken($accessToken)->get($discovery['userinfo_endpoint']);
if ($userinfoResponse->failed()) {
throw new OidcAuthenticationFailedException();
}
return $userinfoResponse->json();
}
/**
* Lie le compte par `oidc_sub`, sinon par `email` ; sinon crée un nouveau user. Ne renseigne
* oidc_sub/name/email/locale que quand l'attribut est encore absent, pour ne jamais écraser un
* profil déjà personnalisé par l'utilisateur.
*
* @param array<string, mixed> $claims
*/
private function upsertUserFromClaims(array $claims): User
{
$sub = $claims['sub'] ?? null;
$email = $claims['email'] ?? null;
if (! is_string($sub) || ! is_string($email)) {
throw new OidcAuthenticationFailedException();
}
$user = User::query()->where('oidc_sub', $sub)->first()
?? User::query()->where('email', $email)->first()
?? new User();
if (blank($user->oidc_sub)) {
$user->oidc_sub = $sub;
}
if (blank($user->email)) {
$user->email = $email;
}
if (blank($user->name)) {
$user->name = $claims['name'] ?? $email;
}
if (blank($user->locale)) {
$user->locale = $claims['locale'] ?? 'fr';
}
if (! $user->exists) {
// Compte OIDC uniquement : la colonne password est NOT NULL mais jamais utilisée pour se connecter.
$user->password = Str::password(40);
}
$user->save();
Streak::firstOrCreate(['user_id' => $user->id]);
return $user;
}
/**
* Récupère (et met en cache une heure) le document de discovery OIDC de pocket-id.
*
* @return array{authorization_endpoint: string, token_endpoint: string, userinfo_endpoint: string}
*/
private function discoverProvider(): array
{
return Cache::remember('oidc.discovery', now()->addHour(), function (): array {
$response = Http::get(rtrim((string) config('services.oidc.issuer'), '/') . '/.well-known/openid-configuration');
if ($response->failed()) {
throw new OidcAuthenticationFailedException();
}
$authorizationEndpoint = $response->json('authorization_endpoint');
$tokenEndpoint = $response->json('token_endpoint');
$userinfoEndpoint = $response->json('userinfo_endpoint');
if (! is_string($authorizationEndpoint) || ! is_string($tokenEndpoint) || ! is_string($userinfoEndpoint)) {
throw new OidcAuthenticationFailedException();
}
return [
'authorization_endpoint' => $authorizationEndpoint,
'token_endpoint' => $tokenEndpoint,
'userinfo_endpoint' => $userinfoEndpoint,
];
});
}
/**
* Génère un code_verifier PKCE aléatoire (RFC 7636 : 43 à 128 caractères base64url).
*/
private function generateCodeVerifier(): string
{
return rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
}
/**
* Dérive le code_challenge S256 du code_verifier PKCE.
*/
private function codeChallenge(string $codeVerifier): string
{
return rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
}
}
@@ -0,0 +1,53 @@
<?php
namespace Functional\Users\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
/**
* Validation + tentative d'authentification du login par mot de passe (POST /login).
* Route publique : l'autorisation ne dépend d'aucun utilisateur déjà authentifié.
*/
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Tente l'authentification par cookie de session. Régénère la session en cas de succès pour
* empêcher la fixation de session, et lève une ValidationException (422) sinon.
*/
public function authenticate(): void
{
$credentials = $this->only('email', 'password');
if (! Auth::attempt($credentials, $this->boolean('remember'))) {
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
$this->session()->regenerate();
}
}
@@ -0,0 +1,36 @@
<?php
namespace Functional\Users\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
/**
* Validation de l'inscription par mot de passe (POST /register). Route publique : n'importe qui
* peut soumettre ce formulaire, l'autorisation ne dépend d'aucun utilisateur déjà authentifié.
*/
class RegisterRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::min(8)],
];
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace Functional\Users\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Functional\Users\Database\Factories\UserFactory;
use Functional\Users\Enums\Units;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
/**
* @property string $locale
* @property Units $units
* @property string|null $timezone
* @property string|null $avatar_path
* @property string|null $oidc_sub
*/
#[Fillable(['name', 'email', 'password', 'locale', 'units', 'timezone', 'avatar_path', 'oidc_sub'])]
#[Hidden(['password', 'remember_token'])]
#[UseFactory(UserFactory::class)]
class User extends Authenticatable implements FilamentUser
{
use HasApiTokens, HasFactory, Notifiable;
/** Accès au back-office Filament (à affiner : rôle admin plus tard). */
public function canAccessPanel(Panel $panel): bool
{
return true;
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'units' => Units::class,
];
}
}
@@ -0,0 +1,28 @@
<?php
namespace Functional\Users\Providers;
use Functional\Users\Database\Seeders\UsersSeeder;
use Functional\Users\Models\User;
use Xefi\LaravelOSDD\LayerServiceProvider;
class UsersServiceProvider extends LayerServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->loadSeeders([UsersSeeder::class]);
}
$this->withRouting(
web: __DIR__ . '/../../routes/web.php',
api: __DIR__ . '/../../routes/api.php',
);
}
public function register(): void
{
config(['auth.providers.users.model' => User::class]);
}
}