diff --git a/.env.base b/.env.base index 483a796d63..89f7abb909 100644 --- a/.env.base +++ b/.env.base @@ -68,6 +68,13 @@ SESSION_TABLE=laravel_sessions QUEUE_CONNECTION=database SANCTUM_STATEFUL_DOMAINS= +# ============================================================================= +# AUTH CONFIGURATION +# ============================================================================= +AUTH_STRATEGY=local # 'local', 'ifixit' +AUTH_REQUIRE_CONSENT=true +AUTH_REQUIRE_API_TOKEN=true + # ============================================================================= # REDIS CONFIGURATION # ============================================================================= diff --git a/.env.template b/.env.template index ffe9907314..aac9dbc527 100644 --- a/.env.template +++ b/.env.template @@ -68,6 +68,13 @@ SESSION_TABLE="$SESSION_TABLE" QUEUE_CONNECTION="$QUEUE_CONNECTION" SANCTUM_STATEFUL_DOMAINS="$SANCTUM_STATEFUL_DOMAINS" +# ============================================================================= +# AUTH CONFIGURATION +# ============================================================================= +AUTH_STRATEGY="$AUTH_STRATEGY" # 'local', 'ifixit' +AUTH_REQUIRE_CONSENT="$AUTH_REQUIRE_CONSENT" +AUTH_REQUIRE_API_TOKEN="$AUTH_REQUIRE_API_TOKEN" + # ============================================================================= # REDIS CONFIGURATION # ============================================================================= diff --git a/app/Http/Controllers/API/GroupController.php b/app/Http/Controllers/API/GroupController.php index 4ea66944d2..f42d94ba4b 100644 --- a/app/Http/Controllers/API/GroupController.php +++ b/app/Http/Controllers/API/GroupController.php @@ -877,10 +877,7 @@ public function createGroupv2(Request $request): JsonResponse { event(new \App\Events\ApproveGroup($group, $data)); // Notify the creator that their group was approved - Notification::send($user, new \App\Notifications\GroupConfirmed([ - 'group_name' => $name, - 'group_url' => url('/group/view/'.$idGroup), - ])); + Notification::send($user, new \App\Notifications\GroupConfirmed($group)); Log::info("Auto-approved group: $idGroup for user {$user->id} (role {$user->role})"); } else { diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 0078c9fc9a..cb13178c06 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -45,6 +45,48 @@ public static function middleware(): array ]; } + /** + * Show the application's login form or redirect to external auth + */ + public function showLoginForm(): View|\Illuminate\Http\RedirectResponse + { + $authManager = app(\App\Services\Auth\AuthStrategyManager::class); + + if ($authManager->isUsingIFixitAuth()) { + // For iFixit, redirect to external login + return redirect($authManager->getLoginUrl(url('/dashboard'))); + } + + // For local auth, show login form + $stats = Fixometer::loginRegisterStats(); + $deviceCount = array_key_exists(0, $stats['device_count_status']) ? $stats['device_count_status'][0]->counter : 0; + + return view('auth.login', [ + 'co2Total' => $stats['waste_stats'][0]->powered_footprint + $stats['waste_stats'][0]->unpowered_footprint, + 'wasteTotal' => $stats['waste_stats'][0]->powered_waste + $stats['waste_stats'][0]->unpowered_waste, + 'partiesCount' => count($stats['allparties']), + 'deviceCount' => $deviceCount, + ]); + } + + /** + * Handle logout for any auth strategy + */ + public function logout(): \Illuminate\Http\RedirectResponse + { + $authManager = app(\App\Services\Auth\AuthStrategyManager::class); + return $authManager->handleLogout(); + } + + /** + * Get login URL for current auth strategy + */ + public function getLoginUrl(string $callbackUrl = null): string + { + $authManager = app(\App\Services\Auth\AuthStrategyManager::class); + return $authManager->getLoginUrl($callbackUrl); + } + /** * Override login from AuthenticateUsers * @@ -97,21 +139,4 @@ protected function validateLogin(Request $request): void 'my_time' => 'required|honeytime:1', ]); } - - /** - * Override showLoginForm from AuthenticateUsers - */ - public function showLoginForm(): View - { - $stats = Fixometer::loginRegisterStats(); - - $deviceCount = array_key_exists(0, $stats['device_count_status']) ? $stats['device_count_status'][0]->counter : 0; - - return view('auth.login', [ - 'co2Total' => $stats['waste_stats'][0]->powered_footprint + $stats['waste_stats'][0]->unpowered_footprint, - 'wasteTotal' => $stats['waste_stats'][0]->powered_waste + $stats['waste_stats'][0]->unpowered_waste, - 'partiesCount' => count($stats['allparties']), - 'deviceCount' => $deviceCount, - ]); - } } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 3af2652d23..fe174a0c8f 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -406,8 +406,15 @@ public function postAdminEdit(Request $request): RedirectResponse return redirect()->back()->with('message', __('profile.admin_success')); } - public function recover(Request $request): View + public function recover(Request $request): View|RedirectResponse { + $authManager = app(\App\Services\Auth\AuthStrategyManager::class); + + // Password recovery not available for external auth + if ($authManager->isUsingIFixitAuth()) { + return redirect('/login')->with('warning', 'Password recovery is not available for iFixit users. Please use iFixit\'s password recovery system.'); + } + $User = new User; $email = $request->get('email'); @@ -460,8 +467,15 @@ public function recover(Request $request): View ]); } - public function reset(Request $request) + public function reset(Request $request): View|RedirectResponse { + $authManager = app(\App\Services\Auth\AuthStrategyManager::class); + + // Password reset not available for external auth + if ($authManager->isUsingIFixitAuth()) { + return redirect('/login')->with('warning', 'Password reset is not available for iFixit users. Please use iFixit\'s password recovery system.'); + } + $User = new User; $user = null; @@ -906,15 +920,18 @@ public function edit($id, Request $request) } } - public function logout(): RedirectResponse - { - Auth::logout(); - return redirect('/login'); - } public function getRegister($hash = null) { + // Check if we should redirect to external registration + $authManager = app(\App\Services\Auth\AuthStrategyManager::class); + + if ($authManager->isUsingIFixitAuth()) { + return redirect($authManager->getRegisterUrl(url('/dashboard'))); + } + + // For local auth, show registration form if (Auth::check() && Auth::user()->hasUserGivenConsent()) { return redirect('dashboard'); } @@ -936,6 +953,15 @@ public function getRegister($hash = null) ]); } + /** + * Get registration URL for current auth strategy + */ + public function getRegisterUrl(string $callbackUrl = null): string + { + $authManager = app(\App\Services\Auth\AuthStrategyManager::class); + return $authManager->getRegisterUrl($callbackUrl); + } + public function postRegister(Request $request, $hash = null): RedirectResponse { $geocoder = new \App\Helpers\Geocoder(); diff --git a/app/Http/Middleware/CentralizedAuth.php b/app/Http/Middleware/CentralizedAuth.php new file mode 100644 index 0000000000..d95269c4e9 --- /dev/null +++ b/app/Http/Middleware/CentralizedAuth.php @@ -0,0 +1,77 @@ +authManager = $authManager; + } + + /** + * Handle an incoming request. + * + * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next + */ + public function handle(Request $request, Closure $next, ?string $mode = null): Response + { + $isOptionalAuth = $mode === 'optional'; + + // For optional auth mode, use default strategy but skip authentication enforcement + $authStrategy = $this->authManager->getStrategy($isOptionalAuth ? null : $mode); + + // Handle optional auth mode: allow public access but run auth logic if authenticated + if ($isOptionalAuth) { + return $this->handleOptionalAuth($request, $next, $authStrategy); + } + + // Standard authentication flow - enforce authentication and consent + return $this->handleRequiredAuth($request, $next, $authStrategy); + } + + /** + * Handle optional authentication: allow public access but run auth logic if user is authenticated + */ + private function handleOptionalAuth(Request $request, Closure $next, $authStrategy): Response + { + // Check authentication before running controller logic + // This allows controllers to see if user is authenticated and act accordingly + $isAuthenticated = $authStrategy->isAuthenticated(); + + $response = $next($request); + + if ($isAuthenticated) { + return $authStrategy->handlePostAuth($request, $response); + } + + return $response; + } + + /** + * Handle required authentication: enforce authentication and consent requirements + */ + private function handleRequiredAuth(Request $request, Closure $next, $authStrategy): Response + { + // Check if user is authenticated + if (!$authStrategy->isAuthenticated()) { + return $authStrategy->getUnauthenticatedResponse(); + } + + // Check if consent is required and given + if ($authStrategy->requiresConsent() && !$authStrategy->hasConsent()) { + return $authStrategy->getConsentResponse(); + } + + // Process the request and handle post-authentication tasks + $response = $next($request); + return $authStrategy->handlePostAuth($request, $response); + } +} \ No newline at end of file diff --git a/app/Http/Middleware/CustomApiTokenAuth.php b/app/Http/Middleware/CustomApiTokenAuth.php index bf32924366..1f6ff97a70 100644 --- a/app/Http/Middleware/CustomApiTokenAuth.php +++ b/app/Http/Middleware/CustomApiTokenAuth.php @@ -5,174 +5,119 @@ use Closure; use Illuminate\Http\Request; use App\Models\User; +use App\Services\Auth\AuthStrategyManager; use Illuminate\Auth\AuthenticationException; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; +/** + * This middleware ensures that API requests are properly authenticated by: + * 1. Checking if user is already authenticated via session + * 2. Trying centralized auth (iFixit, local, etc.) + * 3. Falling back to API token authentication (Bearer header, cookie, query param) + * 4. Ensuring authenticated users have API tokens and setting Authorization header + */ class CustomApiTokenAuth { - /** - * Handle an incoming request. - * This middleware will try multiple methods of authentication to ensure API requests succeed. - */ + private AuthStrategyManager $authManager; + + public function __construct(AuthStrategyManager $authManager) + { + $this->authManager = $authManager; + } + public function handle(Request $request, Closure $next) { - // STEP 1: Log detailed request information - Log::debug("API Request", [ - 'path' => $request->path(), - 'method' => $request->method(), - 'cookies' => array_keys($request->cookies->all()), - 'has_api_token_cookie' => $request->hasCookie('restarters_apitoken'), - 'cookie_value_length' => $request->cookie('restarters_apitoken') ? strlen($request->cookie('restarters_apitoken')) : 0, - 'has_session_cookie' => $request->hasCookie(config('session.cookie')), - 'has_authorization_header' => $request->hasHeader('Authorization'), - 'has_query_token' => $request->has('api_token') - ]); + // Step 1: Try to authenticate user + $this->authenticateUser($request); - // STEP 2: Check if the user is already authenticated via session + // Step 2: If authenticated, ensure API token access if (Auth::check()) { - $user = Auth::user(); - Log::debug("User already authenticated via session", ['user_id' => $user->id]); - - // Make sure the Authorization header is set for downstream middleware/guards - if ($user->api_token) { - $request->headers->set('Authorization', 'Bearer ' . $user->api_token); - return $next($request); - } + $this->ensureApiAccess($request); + return $next($request); } - // STEP 3: Try to authenticate using various methods - $token = $this->extractToken($request); - + // Step 3: Return 401 if not authenticated + return $this->unauthorized($request); + } + + /** + * Try to authenticate the user using available methods + */ + private function authenticateUser(Request $request): void + { + if (Auth::check()) { + return; + } + + $authStrategy = $this->authManager->getStrategy(); + if ($authStrategy->isAuthenticated()) { + return; + } + + $token = $this->extractApiToken($request); if ($token) { $user = User::where('api_token', $token)->first(); - if ($user) { - // Authenticate the user Auth::login($user); - - // Set Authorization header for downstream middleware - $request->headers->set('Authorization', 'Bearer ' . $token); - - Log::debug("API Authentication successful", [ - 'user_id' => $user->id, - 'token_source' => $this->tokenSource - ]); - - return $next($request); - } else { - Log::warning("Invalid API token", [ - 'token_length' => strlen($token), - 'token_source' => $this->tokenSource - ]); } } - - // STEP 4: If we've reached this point, authentication failed - Log::warning("API Authentication failed", [ - 'path' => $request->path(), - 'attempted_methods' => [ - 'session' => Auth::check(), - 'cookie' => $request->hasCookie('restarters_apitoken'), - 'header' => $request->hasHeader('Authorization'), - 'query' => $request->has('api_token'), - ] - ]); - - // For AJAX or API requests, return a JSON response - if ($request->expectsJson() || $request->is('api/*')) { - return response()->json([ - 'message' => 'Unauthenticated.', - 'debug' => [ - 'token_source_tried' => $this->tokenSource, - 'session_auth' => Auth::check(), - 'cookie_auth_attempted' => $request->hasCookie('restarters_apitoken'), - 'header_auth_attempted' => $request->hasHeader('Authorization'), - 'query_auth_attempted' => $request->has('api_token'), - ] - ], 401); - } - - // For web requests, redirect to login page - throw new AuthenticationException( - 'Unauthenticated.', - ['api'], - $request->expectsJson() ? null : route('login') - ); } - - /** - * The source of the token that was used - */ - private $tokenSource = 'none'; - + /** - * Try all possible methods to extract an API token from the request + * Ensure authenticated user has API access */ - private function extractToken(Request $request) + private function ensureApiAccess(Request $request): void { - // Method 1: Check for token in query string - $token = $request->query('api_token'); - if (!empty($token)) { - $this->tokenSource = 'query'; - Log::debug("Found token in query string", ['length' => strlen($token)]); - return $token; - } + $user = Auth::user(); - // Method 2: Check for token in request body - $token = $request->input('api_token'); - if (!empty($token)) { - $this->tokenSource = 'body'; - Log::debug("Found token in request body", ['length' => strlen($token)]); - return $token; - } + $token = $user->ensureAPIToken(); - // Method 3: Check for token in Authorization header + // Set Authorization header for downstream middleware + $request->headers->set('Authorization', "Bearer {$token}"); + } + + /** + * Extract API token from request + */ + private function extractApiToken(Request $request): ?string + { $bearerToken = $request->bearerToken(); - if (!empty($bearerToken)) { - $this->tokenSource = 'bearer'; - Log::debug("Found token in bearer header", ['length' => strlen($bearerToken)]); + if ($bearerToken) { return $bearerToken; } - - // Method 4: Check for token in regular Authorization header - $header = $request->header('Authorization'); - if (strpos($header, 'Bearer ') === 0) { - $token = substr($header, 7); - if (!empty($token)) { - $this->tokenSource = 'auth_header'; - Log::debug("Found token in Authorization header", ['length' => strlen($token)]); - return $token; - } - } - - // Method 5: Check for token in custom header - $token = $request->header('X-API-TOKEN'); - if (!empty($token)) { - $this->tokenSource = 'custom_header'; - Log::debug("Found token in X-API-TOKEN header", ['length' => strlen($token)]); - return $token; - } - - // Method 6: Check for token in cookie - $token = $request->cookie('restarters_apitoken'); - if (!empty($token)) { - $this->tokenSource = 'cookie'; - Log::debug("Found token in cookie", ['length' => strlen($token)]); - return $token; + + $cookieToken = $request->cookie('restarters_apitoken'); + if ($cookieToken) { + return $cookieToken; } - - // Method 7: If user is already authenticated, use their API token - if (Auth::check()) { - $user = Auth::user(); - if (!empty($user->api_token)) { - $this->tokenSource = 'session_user'; - Log::debug("Using token from authenticated user", ['user_id' => $user->id]); - return $user->api_token; - } + + $queryToken = $request->query('api_token'); + if ($queryToken) { + return $queryToken; } - - Log::warning("No token found in request"); + return null; } + + /** + * Return unauthenticated response + */ + private function unauthorized(Request $request) + { + Log::warning('API request failed authentication', [ + 'path' => $request->path(), + 'method' => $request->method(), + 'has_session' => $request->hasSession(), + 'has_api_cookie' => $request->hasCookie('restarters_apitoken'), + ]); + + if ($request->expectsJson() || $request->is('api/*')) { + return response()->json([ + 'message' => 'Unauthenticated.' + ], 401); + } + + throw new AuthenticationException('Unauthenticated.', ['api']); + } } \ No newline at end of file diff --git a/app/Http/Middleware/EnsureAPIToken.php b/app/Http/Middleware/EnsureAPIToken.php deleted file mode 100644 index bec06e124b..0000000000 --- a/app/Http/Middleware/EnsureAPIToken.php +++ /dev/null @@ -1,35 +0,0 @@ -ensureAPIToken(); - - // Return the API token as a cookie. This means it can be picked up by the Vue client. - $response = $next($request); - - if (method_exists($response, 'withCookie')) { - $response->withCookie(cookie()->forever('restarters_apitoken', $token, null, null, false, false)); - } - - return $response; - } else { - return $next($request); - } - } -} diff --git a/app/Http/Middleware/VerifyUserConsent.php b/app/Http/Middleware/VerifyUserConsent.php deleted file mode 100644 index a247c042cc..0000000000 --- a/app/Http/Middleware/VerifyUserConsent.php +++ /dev/null @@ -1,24 +0,0 @@ -hasUserGivenConsent()) { - return $next($request); - } else { - return redirect('/user/register'); - } - } -} diff --git a/app/Models/User.php b/app/Models/User.php index 920614ea69..9cd00516e5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -41,7 +41,7 @@ class User extends Authenticatable implements Auditable, HasLocalePreference * @var array */ protected $fillable = [ - 'name', 'email', 'password', 'role', 'recovery', 'recovery_expires', 'language', 'repair_network', 'location', 'age', 'gender', 'country_code', 'newsletter', 'invites', 'biography', 'consent_future_data', 'consent_past_data', 'consent_gdpr', 'number_of_logins', 'latitude', 'longitude', 'last_login_at', 'api_token', 'access_group_tag_id', 'calendar_hash', 'repairdir_role', 'mediawiki', 'username', + 'name', 'email', 'password', 'role', 'recovery', 'recovery_expires', 'language', 'repair_network', 'location', 'age', 'gender', 'country_code', 'newsletter', 'invites', 'biography', 'consent_future_data', 'consent_past_data', 'consent_gdpr', 'number_of_logins', 'latitude', 'longitude', 'last_login_at', 'api_token', 'access_group_tag_id', 'calendar_hash', 'repairdir_role', 'mediawiki', 'username', 'external_user_id', 'external_username', ]; /** @@ -576,6 +576,69 @@ public function preferredLocale(): string return $this->language ?? config('app.locale', 'en'); } + /** + * Sync user from external service data + */ + public static function syncFromExternal(array $externalUserData): User + { + $user = self::where('external_user_id', $externalUserData['userid'])->first(); + + if ($user) { + // User exists, update without changing role to preserve manual changes + $user->update([ + 'name' => $externalUserData['username'], + 'email' => $externalUserData['login'], + 'external_username' => $externalUserData['unique_username'] ?? null, + 'username' => $externalUserData['unique_username'] ?? null, + ]); + + return $user; + } else { + // User doesn't exist, create with mapped role from iFixit privilege level + $role = Role::RESTARTER; // Default role for external users + + if (isset($externalUserData['greatest_privilege']) && $externalUserData['greatest_privilege'] === 'Admin') { + $role = Role::ADMINISTRATOR; + } + + $user = self::create([ + 'name' => $externalUserData['username'], + 'email' => $externalUserData['login'], + 'external_user_id' => $externalUserData['userid'], + 'external_username' => $externalUserData['unique_username'] ?? null, + 'role' => $role, + 'username' => $externalUserData['unique_username'] ?? null, + 'password' => null, // External users don't have local passwords + 'repairdir_role' => Role::REPAIR_DIRECTORY_NONE, + 'calendar_hash' => Str::random(15) + ]); + + // Generate username if not provided + if (!$user->username) { + $user->generateAndSetUsername(); + $user->save(); + } + + return $user; + } + } + + /** + * Check if this user is an external user (iFixit, etc.) + */ + public function isExternalUser(): bool + { + return !empty($this->external_user_id); + } + + /** + * Check if user can login with password (local users only) + */ + public function canLoginWithPassword(): bool + { + return !$this->isExternalUser() && !empty($this->password); + } + public static function userCanSeeEvent($user, $event) { // We need to filter based on approved visibility: // - where the group is approved, this event is visible diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php new file mode 100644 index 0000000000..81d2380234 --- /dev/null +++ b/app/Providers/AuthServiceProvider.php @@ -0,0 +1,52 @@ +registerPolicies(); + } + + /** + * Register any application services. + * + * @return void + */ + public function register() + { + // Register the iFixit auth service + $this->app->singleton(iFixitAuthService::class, fn () => new iFixitAuthService()); + + // Register the auth strategy manager as a singleton + $this->app->singleton(AuthStrategyManager::class, fn () => new AuthStrategyManager()); + + // Register auth strategies + $this->app->bind(LocalAuthStrategy::class, fn () => new LocalAuthStrategy()); + + $this->app->bind(iFixitAuthStrategy::class, fn () => new iFixitAuthStrategy($this->app->make(iFixitAuthService::class))); + + // Register the current auth strategy based on config + $this->app->bind(AuthStrategyInterface::class, fn () => $this->app->make(AuthStrategyManager::class)->getStrategy()); + } +} \ No newline at end of file diff --git a/app/Services/Auth/AuthStrategyInterface.php b/app/Services/Auth/AuthStrategyInterface.php new file mode 100644 index 0000000000..d9c4b024be --- /dev/null +++ b/app/Services/Auth/AuthStrategyInterface.php @@ -0,0 +1,43 @@ +defaultStrategy = config('restarters.auth.strategy', 'local'); + $this->registerStrategies(); + } + + /** + * Register all available auth strategies + */ + private function registerStrategies(): void + { + $this->strategies = [ + 'local' => LocalAuthStrategy::class, + 'ifixit' => iFixitAuthStrategy::class, + ]; + } + + /** + * Get the current auth strategy instance + */ + public function getStrategy(?string $strategy = null): AuthStrategyInterface + { + $strategy ??= $this->defaultStrategy; + + if (!isset($this->strategies[$strategy])) { + throw new InvalidArgumentException("Auth strategy '{$strategy}' not found"); + } + + return app($this->strategies[$strategy]); + } + + public function isUsingIFixitAuth(): bool + { + return $this->defaultStrategy === 'ifixit'; + } + + /** + * Get the default strategy name + */ + public function getDefaultStrategy(): string + { + return $this->defaultStrategy; + } + + /** + * Set the default strategy + */ + public function setDefaultStrategy(string $strategy): void + { + if (!isset($this->strategies[$strategy])) { + throw new InvalidArgumentException("Auth strategy '{$strategy}' not found"); + } + + $this->defaultStrategy = $strategy; + } + + /** + * Get all available strategies + */ + public function getAvailableStrategies(): array + { + return array_keys($this->strategies); + } + + /** + * Register a custom auth strategy + */ + public function registerStrategy(string $name, string $className): void + { + if (!is_subclass_of($className, AuthStrategyInterface::class)) { + throw new InvalidArgumentException("Class '{$className}' must implement AuthStrategyInterface"); + } + + $this->strategies[$name] = $className; + } + + /** + * Check if a strategy is available + */ + public function hasStrategy(string $strategy): bool + { + return isset($this->strategies[$strategy]); + } + + /** + * Get login URL for current auth strategy + */ + public function getLoginUrl(string $callbackUrl = null): string + { + if ($this->isUsingIFixitAuth()) { + $ifixitService = app(iFixitAuthService::class); + return $ifixitService->getLoginUrl($callbackUrl ?: url('/dashboard')); + } + + return url('/login'); + } + + /** + * Get logout URL for current auth strategy + */ + public function getLogoutUrl(string $callbackUrl = null): string + { + if ($this->isUsingIFixitAuth()) { + $ifixitService = app(iFixitAuthService::class); + return $ifixitService->getLogoutUrl($callbackUrl ?: url('/')); + } + + return url('/logout'); + } + + /** + * Get register URL for current auth strategy + */ + public function getRegisterUrl(string $callbackUrl = null): string + { + if ($this->isUsingIFixitAuth()) { + $ifixitService = app(iFixitAuthService::class); + return $ifixitService->getRegisterUrl($callbackUrl ?: url('/dashboard')); + } + + return url('/user/register'); + } + + /** + * Handle logout for any auth strategy with session flushing + */ + public function handleLogout(): \Illuminate\Http\RedirectResponse + { + $user = \Auth::user(); + $isExternalUser = $user && $user->isExternalUser(); + + // Always logout from Laravel first + \Auth::logout(); + + // Always flush session + session()->flush(); + + // If user is from iFixit, redirect to iFixit logout + if ($isExternalUser && $this->isUsingIFixitAuth()) { + return redirect($this->getLogoutUrl(url('/login'))); + } + + return redirect('/login'); + } +} \ No newline at end of file diff --git a/app/Services/Auth/LocalAuthStrategy.php b/app/Services/Auth/LocalAuthStrategy.php new file mode 100644 index 0000000000..b2d8fd8512 --- /dev/null +++ b/app/Services/Auth/LocalAuthStrategy.php @@ -0,0 +1,70 @@ +requiresConsent = config('restarters.auth.require_consent', true); + $this->requiresApiToken = config('restarters.auth.require_api_token', true); + } + + public function isAuthenticated(): bool + { + return Auth::check() && Auth::user(); + } + + public function hasConsent(): bool + { + if (!$this->requiresConsent) { + return true; + } + + return Auth::check() && Auth::user() && Auth::user()->hasUserGivenConsent(); + } + + public function requiresConsent(): bool + { + return $this->requiresConsent; + } + + public function getUnauthenticatedResponse(): Response + { + return redirect()->guest(route('login')); + } + + public function getConsentResponse(): Response + { + return redirect('/user/register'); + } + + public function handlePostAuth($_, Response $response): Response + { + if (!$this->requiresApiToken || !Auth::check()) { + return $response; + } + + // Ensure API token for Vue client + $token = Auth::user()->ensureAPIToken(); + + // Add API token as cookie + if (method_exists($response, 'withCookie')) { + $response->withCookie(cookie()->forever('restarters_apitoken', $token, null, null, false, false)); + } + + return $response; + } + + public function getName(): string + { + return 'local'; + } +} \ No newline at end of file diff --git a/app/Services/Auth/iFixitAuthService.php b/app/Services/Auth/iFixitAuthService.php new file mode 100644 index 0000000000..e5ca686931 --- /dev/null +++ b/app/Services/Auth/iFixitAuthService.php @@ -0,0 +1,98 @@ + 'application/json', + 'Cookie' => "session={$sessionCookie}", + 'User-Agent' => 'RestartProject/1.0', + ])->get("{$this->apiUrl}/user"); + + if ($response->successful()) { + $userData = $response->json(); + + // Validate required fields + if (!isset($userData['userid']) || !isset($userData['login'])) { + return null; + } + + return $userData; + } + + return null; + } catch (\Exception $e) { + Log::error('iFixit API validation failed', [ + 'error' => $e->getMessage(), + 'session_length' => strlen($sessionCookie) + ]); + return null; + } + } + + /** + * Get iFixit login URL with callback + */ + public function getLoginUrl(string $callbackUrl): string + { + return "{$this->baseUrl}/login?last_page=" . urlencode($callbackUrl); + } + + public function getRegisterUrl(string $callbackUrl): string + { + return "{$this->baseUrl}/Join?last_page=" . urlencode($callbackUrl); + } + + /** + * Get iFixit logout URL with callback + */ + public function getLogoutUrl(string $callbackUrl): string + { + return "{$this->baseUrl}/user/logout?last_page=" . urlencode($callbackUrl); + } + + /** + * Check if user is authenticated with iFixit + */ + public function isAuthenticated(): bool + { + $sessionCookie = request()->cookie('session'); + if (!$sessionCookie) { + return false; + } + + $userData = $this->validateSession($sessionCookie); + return $userData !== null; + } + + /** + * Get current user data from iFixit session + */ + public function getCurrentUser(): ?array + { + $sessionCookie = request()->cookie('session'); + if (!$sessionCookie) { + return null; + } + + return $this->validateSession($sessionCookie); + } +} \ No newline at end of file diff --git a/app/Services/Auth/iFixitAuthStrategy.php b/app/Services/Auth/iFixitAuthStrategy.php new file mode 100644 index 0000000000..59cd826043 --- /dev/null +++ b/app/Services/Auth/iFixitAuthStrategy.php @@ -0,0 +1,108 @@ +requiresApiToken = config('restarters.auth.require_api_token', true); + $this->ifixitService = $ifixitService; + } + + public function isAuthenticated(): bool + { + // First check if user is already authenticated in Laravel + if (Auth::check()) { + return true; + } + + // Check if user has valid iFixit session + $sessionCookie = request()->cookie('session'); + if (!$sessionCookie) { + return false; + } + + // Validate session with iFixit API + $userData = $this->ifixitService->validateSession($sessionCookie); + if (!$userData) { + return false; + } + + try { + // Sync/create user from iFixit data + $user = User::syncFromExternal($userData); + + // Log the user in + Auth::login($user); + + Log::debug('iFixit user authenticated', [ + 'user_id' => $user->id, + 'external_id' => $userData['userid'], + 'email' => $userData['login'] + ]); + + return true; + } catch (\Exception $e) { + Log::error('Failed to sync iFixit user', [ + 'error' => $e->getMessage(), + 'user_data' => $userData + ]); + return false; + } + } + + public function hasConsent(): bool + { + // iFixit users typically bypass consent + return true; + } + + public function requiresConsent(): bool + { + return false; + } + + public function getUnauthenticatedResponse(): Response + { + // Redirect to iFixit login page with callback + $callbackUrl = url('/dashboard'); + $loginUrl = $this->ifixitService->getLoginUrl($callbackUrl); + + return redirect($loginUrl); + } + + public function getConsentResponse(): Response + { + return redirect()->back(); + } + + public function handlePostAuth($_, Response $response): Response { + if (!$this->requiresApiToken || !Auth::check()) { + return $response; + } + + // Ensure API token for Vue client + $token = Auth::user()->ensureAPIToken(); + + // Add API token as cookie + if (method_exists($response, 'withCookie')) { + $response->withCookie(cookie()->forever('restarters_apitoken', $token, null, null, false, false)); + } + + return $response; + } + + public function getName(): string + { + return 'ifixit'; + } +} \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php index a4bef9cce8..06da62039a 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,6 +12,7 @@ ->withProviders([ // App Service Providers \App\Providers\TranslationServiceProvider::class, + \App\Providers\AuthServiceProvider::class, // Package Service Providers \Mariuzzo\LaravelJsLocalization\LaravelJsLocalizationServiceProvider::class, @@ -57,7 +58,8 @@ 'wiki_dev_mw_UserID', 'wiki_dev_mw_UserName', 'authenticated', - 'restarters_apitoken' + 'restarters_apitoken', + 'session' ]); $middleware->append(\App\Http\Middleware\HttpsProtocol::class); @@ -81,14 +83,13 @@ $middleware->alias([ 'AcceptUserInvites' => \App\Http\Middleware\AcceptUserInvites::class, - 'ensureAPIToken' => \App\Http\Middleware\EnsureAPIToken::class, 'customApiAuth' => \App\Http\Middleware\CustomApiTokenAuth::class, 'admin' => \App\Http\Middleware\AdminMiddleware::class, 'localeSessionRedirect' => \Mcamara\LaravelLocalization\Middleware\LocaleSessionRedirect::class, 'localeViewPath' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationViewPath::class, 'localizationRedirect' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRedirectFilter::class, 'localize' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRoutes::class, - 'verifyUserConsent' => \App\Http\Middleware\VerifyUserConsent::class, + 'centralizedAuth' => \App\Http\Middleware\CentralizedAuth::class, ]); $middleware->priority([ diff --git a/charts/restarters/Chart.yaml b/charts/restarters/Chart.yaml index 1bc2e2dcc8..9b8d67aa6d 100644 --- a/charts/restarters/Chart.yaml +++ b/charts/restarters/Chart.yaml @@ -15,7 +15,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.2.1 +version: 0.2.2 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to diff --git a/charts/restarters/templates/env-config.yaml b/charts/restarters/templates/env-config.yaml index da589bf65b..7bf4407a0c 100644 --- a/charts/restarters/templates/env-config.yaml +++ b/charts/restarters/templates/env-config.yaml @@ -24,6 +24,9 @@ data: # Session and Cache Configuration {{- include "restarters.envGroup" (dict "groupName" "session" "context" .) | nindent 8 }} + # Auth Configuration + {{- include "restarters.envGroup" (dict "groupName" "auth" "context" .) | nindent 8 }} + # Redis Configuration {{- include "restarters.envGroup" (dict "groupName" "redis" "context" .) | nindent 8 }} diff --git a/charts/restarters/values.yaml b/charts/restarters/values.yaml index 6d573c2c8d..214e153c6d 100644 --- a/charts/restarters/values.yaml +++ b/charts/restarters/values.yaml @@ -266,6 +266,11 @@ envGroups: QUEUE_CONNECTION: "sync" SANCTUM_STATEFUL_DOMAINS: "" + auth: + AUTH_STRATEGY: "local" + AUTH_REQUIRE_CONSENT: "true" + AUTH_REQUIRE_API_TOKEN: "true" + # Redis configuration redis: REDIS_HOST: "127.0.0.1" @@ -338,19 +343,6 @@ envGroups: MAPBOX_TOKEN: "" GOOGLE_API_CONSOLE_KEY: "" - # Monitoring - SEND_COMMAND_LOGS_TO: "" - CALENDAR_HASH: "" - SENTRY_LARAVEL_DSN: "" - SENTRY_TRACES_SAMPLE_RATE: "1" - - # Security - HONEYPOT_DISABLE: "false" - L5_SWAGGER_GENERATE_ALWAYS: "false" - - # Meta - META_TWITTER_SITE: "" - META_TWITTER_IMAGE_ALT: "" # Monitoring and analytics monitoring: SENTRY_LARAVEL_DSN: "" diff --git a/config/restarters.php b/config/restarters.php index 4485000a12..0bddff53ee 100644 --- a/config/restarters.php +++ b/config/restarters.php @@ -9,6 +9,12 @@ 'matomo_integration' => env('FEATURE__MATOMO_INTEGRATION', false), ], + 'auth' => [ + 'strategy' => env('AUTH_STRATEGY', 'local'), // 'local', 'ifixit' + 'require_consent' => env('AUTH_REQUIRE_CONSENT', true), // Set to false for iFixit + 'require_api_token' => env('AUTH_REQUIRE_API_TOKEN', true), + ], + 'wiki' => [ 'base_url' => env('WIKI_URL'), 'cookie_prefix' => env('WIKI_COOKIE_PREFIX', 'wiki_db'), diff --git a/database/migrations/2025_07_16_212610_add_external_auth_fields_to_users_table.php b/database/migrations/2025_07_16_212610_add_external_auth_fields_to_users_table.php new file mode 100644 index 0000000000..01735e7107 --- /dev/null +++ b/database/migrations/2025_07_16_212610_add_external_auth_fields_to_users_table.php @@ -0,0 +1,32 @@ +string('external_user_id')->nullable()->unique()->after('mediawiki'); + $table->string('external_username')->nullable()->after('external_user_id'); + $table->string('password')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('external_user_id'); + $table->dropColumn('external_username'); + $table->string('password')->nullable(false)->change(); + }); + } +}; diff --git a/resources/js/components/EventActions.vue b/resources/js/components/EventActions.vue index 3bac4e7660..5ad6961613 100644 --- a/resources/js/components/EventActions.vue +++ b/resources/js/components/EventActions.vue @@ -26,13 +26,15 @@
- + {{ __('events.invite_volunteers') }} - + {{ __('events.invite_volunteers') }} - + {{ __('events.RSVP') }} @@ -48,7 +50,8 @@ {{ __('events.follow_group') }} - + {{ __('events.invite_volunteers') }} @@ -97,6 +100,11 @@ export default { default: false }, }, + computed: { + isLocalAuth() { + return (window.Laravel?.authStrategy || 'local') === 'local' + } + }, methods: { confirmDelete() { this.$refs.confirmdelete.show() diff --git a/resources/js/components/EventAddVolunteerModal.vue b/resources/js/components/EventAddVolunteerModal.vue index 306fe24348..a148a0d016 100644 --- a/resources/js/components/EventAddVolunteerModal.vue +++ b/resources/js/components/EventAddVolunteerModal.vue @@ -1,19 +1,15 @@