Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user