implement outlook login
This commit is contained in:
parent
2140c12b10
commit
d1637c5a35
185
app/Http/Controllers/OutlookController.php
Normal file
185
app/Http/Controllers/OutlookController.php
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\OutlookToken;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class OutlookController extends Controller
|
||||||
|
{
|
||||||
|
// ── OAuth config from .env ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private string $tenantId;
|
||||||
|
private string $clientId;
|
||||||
|
private string $clientSecret;
|
||||||
|
private string $redirectUri;
|
||||||
|
|
||||||
|
private const SCOPES = 'openid profile email offline_access User.Read Mail.Read Mail.ReadWrite Mail.Send';
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->tenantId = config('services.microsoft.tenant_id');
|
||||||
|
$this->clientId = config('services.microsoft.client_id');
|
||||||
|
$this->clientSecret = config('services.microsoft.client_secret');
|
||||||
|
$this->redirectUri = route('outlook.callback');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test page ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
$token = auth()->user()?->outlookToken;
|
||||||
|
|
||||||
|
$connected = $token && ! $token->isExpired();
|
||||||
|
$outlookUrl = null;
|
||||||
|
|
||||||
|
if ($connected) {
|
||||||
|
// Silent redirect URL — opens Outlook web with the user already signed in
|
||||||
|
// via SSO cookie / session; no token is passed in the URL (not safe).
|
||||||
|
// Instead we redirect to Outlook and the browser SSO session handles it.
|
||||||
|
$outlookUrl = 'https://outlook.office.com/mail/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('test.outlook', compact('connected', 'outlookUrl', 'token'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 1: Redirect user to Microsoft login ──────────────────────────────
|
||||||
|
|
||||||
|
public function connect(): RedirectResponse
|
||||||
|
{
|
||||||
|
$state = Str::random(40);
|
||||||
|
session(['oauth_state' => $state]);
|
||||||
|
|
||||||
|
$query = http_build_query([
|
||||||
|
'client_id' => $this->clientId,
|
||||||
|
'response_type' => 'code',
|
||||||
|
'redirect_uri' => $this->redirectUri,
|
||||||
|
'response_mode' => 'query',
|
||||||
|
'scope' => self::SCOPES,
|
||||||
|
'state' => $state,
|
||||||
|
// prompt=none tries silent login first; falls back to login page if needed
|
||||||
|
'prompt' => 'select_account',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect("https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: Handle callback, exchange code for tokens ────────────────────
|
||||||
|
|
||||||
|
public function callback(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
// CSRF state check
|
||||||
|
if ($request->state !== session('oauth_state')) {
|
||||||
|
return redirect()->route('outlook.test')->with('error', 'Invalid OAuth state. Please try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->has('error')) {
|
||||||
|
return redirect()->route('outlook.test')->with('error', $request->error_description ?? 'Microsoft login failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange authorization code for tokens
|
||||||
|
$response = Http::asForm()->post(
|
||||||
|
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
|
||||||
|
[
|
||||||
|
'client_id' => $this->clientId,
|
||||||
|
'client_secret' => $this->clientSecret,
|
||||||
|
'code' => $request->code,
|
||||||
|
'redirect_uri' => $this->redirectUri,
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
return redirect()->route('outlook.test')->with('error', 'Failed to retrieve tokens from Microsoft.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
// Persist token for this user (upsert so re-connecting just updates)
|
||||||
|
OutlookToken::updateOrCreate(
|
||||||
|
['user_id' => auth()->id()],
|
||||||
|
[
|
||||||
|
'access_token' => $data['access_token'],
|
||||||
|
'refresh_token' => $data['refresh_token'] ?? null,
|
||||||
|
'token_type' => $data['token_type'] ?? 'Bearer',
|
||||||
|
'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('outlook.test')->with('success', 'Outlook connected successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Open Outlook (refresh token silently if expired) ─────────────────────
|
||||||
|
|
||||||
|
public function openOutlook(): RedirectResponse
|
||||||
|
{
|
||||||
|
$token = auth()->user()?->outlookToken;
|
||||||
|
|
||||||
|
if (! $token) {
|
||||||
|
return redirect()->route('outlook.connect');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If token is expired, try to refresh silently
|
||||||
|
if ($token->isExpired()) {
|
||||||
|
$refreshed = $this->refreshToken($token);
|
||||||
|
|
||||||
|
if (! $refreshed) {
|
||||||
|
// Refresh failed — force re-login
|
||||||
|
$token->delete();
|
||||||
|
return redirect()->route('outlook.connect');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to Outlook Web — SSO session handles silent sign-in
|
||||||
|
return redirect('https://outlook.office.com/mail/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Disconnect ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function disconnect(): RedirectResponse
|
||||||
|
{
|
||||||
|
auth()->user()?->outlookToken?->delete();
|
||||||
|
|
||||||
|
return redirect()->route('outlook.test')->with('success', 'Outlook disconnected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal: Refresh access token using refresh_token ───────────────────
|
||||||
|
|
||||||
|
private function refreshToken(OutlookToken $token): bool
|
||||||
|
{
|
||||||
|
if (! $token->refresh_token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::asForm()->post(
|
||||||
|
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
|
||||||
|
[
|
||||||
|
'client_id' => $this->clientId,
|
||||||
|
'client_secret' => $this->clientSecret,
|
||||||
|
'refresh_token' => $token->refresh_token,
|
||||||
|
'grant_type' => 'refresh_token',
|
||||||
|
'scope' => self::SCOPES,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
$token->update([
|
||||||
|
'access_token' => $data['access_token'],
|
||||||
|
'refresh_token' => $data['refresh_token'] ?? $token->refresh_token,
|
||||||
|
'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Models/OutlookToken.php
Normal file
33
app/Models/OutlookToken.php
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class OutlookToken extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'access_token',
|
||||||
|
'refresh_token',
|
||||||
|
'token_type',
|
||||||
|
'expires_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isExpired(): bool
|
||||||
|
{
|
||||||
|
return $this->expires_at->isPast();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@
|
|||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
use Spatie\Permission\Traits\HasRoles;
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
|
||||||
#[Fillable(['name', 'email', 'password', 'immutable_id'])]
|
#[Fillable(['name', 'email', 'password', 'immutable_id'])]
|
||||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||||
@ -60,4 +61,9 @@ protected function casts(): array
|
|||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function outlookToken(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(OutlookToken::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,4 +46,10 @@
|
|||||||
'mfa_behavior' => env('MICROSOFT_GRAPH_MFA_BEHAVIOR', 'acceptIfMfaDoneByFederatedIdp'),
|
'mfa_behavior' => env('MICROSOFT_GRAPH_MFA_BEHAVIOR', 'acceptIfMfaDoneByFederatedIdp'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'microsoft' => [
|
||||||
|
'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'),
|
||||||
|
'client_id' => env('MICROSOFT_GRAPH_CLIENT_ID'),
|
||||||
|
'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
<?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('outlook_tokens', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->text('access_token');
|
||||||
|
$table->text('refresh_token')->nullable();
|
||||||
|
$table->string('token_type')->default('Bearer');
|
||||||
|
$table->timestamp('expires_at');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique('user_id'); // one token record per user
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('outlook_tokens');
|
||||||
|
}
|
||||||
|
};
|
||||||
103
resources/views/test/outlook.blade.php
Normal file
103
resources/views/test/outlook.blade.php
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Outlook Connect Test</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
|
||||||
|
<div class="bg-white rounded-2xl shadow-lg p-10 w-full max-w-md text-center space-y-6">
|
||||||
|
|
||||||
|
{{-- Logo --}}
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-14 h-14" viewBox="0 0 48 48">
|
||||||
|
<rect width="26" height="26" x="2" y="11" fill="#0078d4" rx="2"/>
|
||||||
|
<path fill="#fff" d="M15 18a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm0 12a5 5 0 1 1 0-10 5 5 0 0 1 0 10z"/>
|
||||||
|
<path fill="#0078d4" d="M28 11h16a2 2 0 0 1 2 2v22a2 2 0 0 1-2 2H28V11z"/>
|
||||||
|
<path fill="#fff" d="M30 17h14v2H30zm0 4h14v2H30zm0 4h14v2H30zm0 4h8v2h-8z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-800">Outlook Integration</h1>
|
||||||
|
<p class="text-gray-500 text-sm mt-1">Connect your Microsoft Outlook account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Flash messages --}}
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="bg-green-50 border border-green-200 text-green-700 rounded-lg px-4 py-3 text-sm">
|
||||||
|
✅ {{ session('success') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(session('error'))
|
||||||
|
<div class="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 text-sm">
|
||||||
|
❌ {{ session('error') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Status card --}}
|
||||||
|
<div class="bg-gray-50 rounded-xl px-6 py-4 text-sm text-left space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-600 font-medium">Status</span>
|
||||||
|
@if($connected)
|
||||||
|
<span class="inline-flex items-center gap-1 text-green-600 font-semibold">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-green-500 inline-block"></span> Connected
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="inline-flex items-center gap-1 text-gray-400 font-semibold">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-gray-300 inline-block"></span> Not connected
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($connected && $token)
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-600">Token expires</span>
|
||||||
|
<span class="text-gray-700 font-mono text-xs">{{ $token->expires_at->diffForHumans() }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Action buttons --}}
|
||||||
|
@if($connected)
|
||||||
|
{{-- Open Outlook --}}
|
||||||
|
<a href="{{ route('outlook.open') }}"
|
||||||
|
class="flex items-center justify-center gap-2 w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-xl transition duration-150">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
Open Outlook
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{-- Disconnect --}}
|
||||||
|
<form method="POST" action="{{ route('outlook.disconnect') }}">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full text-sm text-red-500 hover:text-red-700 underline transition">
|
||||||
|
Disconnect Outlook
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
@else
|
||||||
|
{{-- Connect button --}}
|
||||||
|
<a href="{{ route('outlook.connect') }}"
|
||||||
|
class="flex items-center justify-center gap-2 w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-xl transition duration-150">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M21.35 11.1H12.18V13.83H18.69C18.36 17.64 15.19 19.27 12.19 19.27C8.36 19.27 5 16.25 5 12C5 7.9 8.2 4.73 12.2 4.73C15.29 4.73 17.1 6.7 17.1 6.7L19 4.72C19 4.72 16.56 2 12.1 2C6.42 2 2.03 6.8 2.03 12C2.03 17.05 6.16 22 12.25 22C17.6 22 21.5 18.33 21.5 12.91C21.5 11.76 21.35 11.1 21.35 11.1Z"/>
|
||||||
|
</svg>
|
||||||
|
Connect to Outlook
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
You'll be redirected to Microsoft login. After connecting, you won't need to log in again.
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Http\Controllers\{ResolveDashboardRouteController, SamlIdpController};
|
use App\Http\Controllers\{ResolveDashboardRouteController, SamlIdpController};
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware};
|
use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware};
|
||||||
|
use App\Http\Controllers\OutlookController;
|
||||||
|
|
||||||
Route::redirect('/', '/dashboard')->name('home');
|
Route::redirect('/', '/dashboard')->name('home');
|
||||||
|
|
||||||
@ -81,4 +82,22 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::middleware(['auth', 'verified'])->prefix('test')->name('outlook.')->group(function (): void {
|
||||||
|
|
||||||
|
// Test page with Connect button
|
||||||
|
Route::get('outlook', [OutlookController::class, 'index'])->name('test');
|
||||||
|
|
||||||
|
// Step 1: Kick off OAuth flow
|
||||||
|
Route::get('outlook/connect', [OutlookController::class, 'connect'])->name('connect');
|
||||||
|
|
||||||
|
// Step 2: Microsoft redirects back here with auth code
|
||||||
|
Route::get('outlook/callback', [OutlookController::class, 'callback'])->name('callback');
|
||||||
|
|
||||||
|
// Open Outlook (refreshes token silently if expired)
|
||||||
|
Route::get('outlook/open', [OutlookController::class, 'openOutlook'])->name('open');
|
||||||
|
|
||||||
|
// Disconnect / revoke saved token
|
||||||
|
Route::delete('outlook/disconnect', [OutlookController::class, 'disconnect'])->name('disconnect');
|
||||||
|
});
|
||||||
|
|
||||||
require __DIR__.'/settings.php';
|
require __DIR__.'/settings.php';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user