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
+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]);
}
}