Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **`RegistrationService`** — central registration logic (validation rules, user creation, invitation acceptance with `InvalidInvitationException`, federated-domain team rules, OAuth registration, transaction ownership, `Registered` event); previously duplicated with drift across four controllers. `Domain::isVerifiedForEmail()` replaces five copies of the domain-verification check; unused `MembershipService` removed
- **SPA consumer guide** (`docs/spa-authentication.md`) — completes SPA cookie mode phase 4: backend/CORS/axios setup, all auth flows with exact response shapes, the SSO → SPA hand-off, and troubleshooting
- Documentation: identity-mode decision matrix (multi-tenancy.md), authoritative middleware usage & ordering guide (architecture-internals.md), queue/tenant-context job pattern (multi-tenancy.md), and a verified, prominent warning that OAuth login bypasses MFA and password policies with mitigations (authentication.md, security.md)
- **Headless core + Blade starter kit (RFC 002, phase A)** — the package is now fully headless by default, Fortify-style:
Expand Down
6 changes: 3 additions & 3 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,9 @@
- [x] **CORS/SPA guidance** — covered in `docs/spa-authentication.md` (backend setup: cors.php example, credentials, encryption caveats).

### Code Cleanup
- [ ] **Remove unused `MembershipService`** — `src/Services/MembershipService.php` is defined but never injected or referenced. Either implement it as the central membership operations service or remove it.
- [ ] **Extract `isDomainVerified()` to shared service** — Currently duplicated as a private method in `UserAuthController`, `UserAuthApiController`, and `OAuthController`. Move to `EmailDomainValidator` service.
- [ ] **Web/API registration feature parity** — Web and API registration controllers have diverged in feature coverage. Consider extracting a `RegistrationService` to centralize: company email enforcement, tenant isolation handling, username support, team creation with activation.
- [x] **Remove unused `MembershipService`** — removed (verified: never injected or referenced).
- [x] **Extract `isDomainVerified()`** — the four private copies (plus one inline variant) replaced by `Domain::isVerifiedForEmail()`. (The old note's `EmailDomainValidator` target no longer exists.)
- [x] **Web/API registration feature parity** — `RegistrationService` now owns validation rules, user creation, invitation acceptance, federated-domain team rules, OAuth registration, the transaction, and the `Registered` event; all four controllers delegate to it. Future hooks (email reputation, RFC-001 self-registration routing) plug in here.
- [x] ~~Lint violations in demo/ directory~~ — obsolete: `demo/` is gitignored (local-only, not shipped).

---
Expand Down
6 changes: 6 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ parameters:
-
message: '#^Access to an undefined property Laravel\\Socialite\\Contracts\\User\:\:\$email\.$#'
identifier: property.notFound
count: 2
path: src/Http/Controllers/Auth/OAuthController.php

-
message: '#^Access to an undefined property Laravel\\Socialite\\Contracts\\User\:\:\$name\.$#'
identifier: property.notFound
count: 1
path: src/Http/Controllers/Auth/OAuthController.php

Expand Down
10 changes: 10 additions & 0 deletions src/Exceptions/InvalidInvitationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Ssntpl\Neev\Exceptions;

use Exception;

class InvalidInvitationException extends Exception
{
protected $message = 'Invalid or expired invitation link.';
}
67 changes: 3 additions & 64 deletions src/Http/Controllers/Auth/OAuthApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,14 @@
namespace Ssntpl\Neev\Http\Controllers\Auth;

use Exception;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;
use Ssntpl\Neev\Http\Controllers\Controller;
use Ssntpl\Neev\Models\Domain;
use Ssntpl\Neev\Models\Team;
use Ssntpl\Neev\Models\User;
use Ssntpl\Neev\Services\AuthService;
use Ssntpl\Neev\Services\GeoIP;
use Ssntpl\Neev\Services\RegistrationService;
use Ssntpl\Neev\Services\SpaCookieResponder;

class OAuthApiController extends Controller
Expand Down Expand Up @@ -81,12 +77,8 @@ public function callback(Request $request, string $service, GeoIP $geoIP)
], 401);
}
} else {
$user = $this->register($oauthUser);
if (!$user) {
return response()->json([
'message' => 'Unable to register user.',
], 500);
}
$user = app(RegistrationService::class)
->registerViaOAuth($oauthUser->name, $oauthUser->email);
}

$expiryMinutes = config('neev.login_token_expiry_minutes', 1440);
Expand All @@ -113,57 +105,4 @@ public function callback(Request $request, string $service, GeoIP $geoIP)
}
}

private function register($oauthUser)
{
DB::beginTransaction();
$userData = [
'name' => $oauthUser->name,
'email' => $oauthUser->email,
'email_verified_at' => now(),
];

if (config('neev.support_username')) {
$base = explode('@', $oauthUser->email)[0];
$username = $base;
while (User::getModel()->where('username', $username)->first()) {
$username = $base . '_' . Str::random(4);
}
$userData['username'] = $username;
}

$user = User::model()->forceCreate($userData);

try {
if (config('neev.team')) {
$shouldCreateTeam = !$this->isDomainVerified($oauthUser->email);

if ($shouldCreateTeam) {
$team = Team::model()->forceCreate([
'name' => explode(' ', $user->name, 2)[0] . "'s Team",
'user_id' => $user->id,
'is_public' => false,
'activated_at' => now(),
]);
$team->addMember($user);
}
}
} catch (Exception $e) {
DB::rollBack();
Log::error($e);
return null;
}
DB::commit();

event(new Registered($user));

return $user;
}

private function isDomainVerified(string $email): bool
{
$emailDomain = substr(strrchr($email, "@"), 1);
$domain = Domain::where('domain', $emailDomain)->first();

return $domain?->verified_at !== null;
}
}
66 changes: 6 additions & 60 deletions src/Http/Controllers/Auth/OAuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,14 @@
namespace Ssntpl\Neev\Http\Controllers\Auth;

use Exception;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;
use Ssntpl\Neev\Http\Controllers\Controller;
use Ssntpl\Neev\Models\Domain;
use Ssntpl\Neev\Models\Team;
use Ssntpl\Neev\Models\User;
use Ssntpl\Neev\Services\AuthService;
use Ssntpl\Neev\Services\GeoIP;
use Ssntpl\Neev\Services\RegistrationService;
use Ssntpl\Neev\Services\SpaCookieResponder;
use Ssntpl\Neev\Services\StatefulOriginResolver;

Expand Down Expand Up @@ -60,8 +56,11 @@ public function callback(Request $request, string $service, GeoIP $geoIP)
return redirect(route('login'));
}
} else {
$user = $this->register($oauthUser);
if (!$user) {
try {
$user = app(RegistrationService::class)
->registerViaOAuth($oauthUser->name, $oauthUser->email);
} catch (Exception $e) {
Log::error($e);
return redirect(route('register'));
}
}
Expand All @@ -87,57 +86,4 @@ public function callback(Request $request, string $service, GeoIP $geoIP)
return $response;
}

public function register($oauthUser)
{
DB::beginTransaction();
$userData = [
'name' => $oauthUser->name,
'email' => $oauthUser->email,
'email_verified_at' => now(),
];

if (config('neev.support_username')) {
$base = explode('@', $oauthUser->email)[0];
$username = $base;
while (User::getModel()->where('username', $username)->first()) {
$username = $base . '_' . Str::random(4);
}
$userData['username'] = $username;
}

$user = User::model()->forceCreate($userData);

try {
if (config('neev.team')) {
$shouldCreateTeam = !$this->isDomainVerified($oauthUser->email);

if ($shouldCreateTeam) {
$team = Team::model()->forceCreate([
'name' => explode(' ', $user->name, 2)[0] . "'s Team",
'user_id' => $user->id,
'is_public' => false,
'activated_at' => now(),
]);
$team->addMember($user);
}
}
} catch (Exception $e) {
DB::rollBack();
Log::error($e);
return null;
}
DB::commit();

event(new Registered($user));

return $user;
}

private function isDomainVerified(string $email): bool
{
$emailDomain = substr(strrchr($email, "@"), 1);
$domain = Domain::where('domain', $emailDomain)->first();

return $domain?->verified_at !== null;
}
}
88 changes: 12 additions & 76 deletions src/Http/Controllers/Auth/UserAuthApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,97 +5,39 @@
use Exception;
use Firebase\JWT\JWT;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Auth\Events\Registered;
use Illuminate\Validation\ValidationException;
use Ssntpl\Neev\Events\LoggedOut;
use Ssntpl\Neev\Exceptions\InvalidInvitationException;
use Ssntpl\Neev\Http\Controllers\Controller;
use Ssntpl\Neev\Mail\LoginUsingLink;
use Ssntpl\Neev\Mail\VerifyUserEmail;
use Ssntpl\Neev\Models\AccessToken;
use Ssntpl\Neev\Models\Domain;
use Ssntpl\Neev\Models\LoginAttempt;
use Ssntpl\Neev\Models\Team;
use Ssntpl\Neev\Models\TeamInvitation;
use Ssntpl\Neev\Models\User;
use Ssntpl\Neev\Services\AuthService;
use Ssntpl\Neev\Services\GeoIP;
use Ssntpl\Neev\Services\JwtSecret;
use Ssntpl\Neev\Services\RegistrationService;
use Ssntpl\Neev\Services\SpaCookieResponder;

class UserAuthApiController extends Controller
{
public function register(Request $request, GeoIP $geoIP)
{
$validationRules = [
'name' => 'required|string|max:255',
'email' => ['required', 'string', 'email', 'max:255', User::uniqueEmailRule()],
'password' => config('neev.password'),
];

if (config('neev.support_username')) {
$validationRules['username'] = config('neev.username');
}

try {
$request->validate($validationRules);
DB::beginTransaction();

$userData = [
'name' => $request->name,
'email' => $request->email,
'password' => $request->password,
'password_changed_at' => now(),
];

if (config('neev.support_username')) {
$userData['username'] = $request->username;
}
$request->validate(app(RegistrationService::class)->rules());

$user = User::model()->forceCreate($userData);
$user = User::model()->find($user->id);

if (config('neev.team')) {
if ($request->invitation_id) {
$invitation = TeamInvitation::find($request->invitation_id);
if (!$invitation || !hash_equals(sha1($invitation->email), $request->hash)) {
DB::rollBack();
return response()->json([
'message' => 'Invalid or expired invitation link.',
], 400);
}

$user->markEmailAsVerified();

$team = $invitation->team;
$team->users()->attach($user, ['joined' => true]);
if ($invitation->role) {
$user->assignRole($invitation->role, $team);
}
$invitation->delete();
} else {
$shouldCreateTeam = !$this->isDomainVerified($request->email);

if ($shouldCreateTeam) {
$team = Team::model()->forceCreate([
'name' => explode(' ', $user->name, 2)[0] . "'s Team",
'user_id' => $user->id,
'is_public' => false,
'activated_at' => now(),
]);
$team->addMember($user);
}
}
}
DB::commit();

event(new Registered($user));
$user = app(RegistrationService::class)->register(
$request->only(['name', 'email', 'password', 'username']),
$request->invitation_id,
$request->hash,
);

if (!$user->hasVerifiedEmail()) {
app(AuthService::class)->sendEmailVerification($user);
Expand All @@ -116,10 +58,12 @@ public function register(Request $request, GeoIP $geoIP)
'email_verified' => $user->hasVerifiedEmail(),
]), $expiryMinutes);
} catch (ValidationException $e) {
DB::rollBack();
throw $e;
} catch (InvalidInvitationException $e) {
return response()->json([
'message' => $e->getMessage(),
], 400);
} catch (Exception $e) {
DB::rollBack();
Log::error($e);
return response()->json([
'message' => 'Unable to register user.',
Expand Down Expand Up @@ -559,14 +503,6 @@ public function verifyEmailChange(Request $request)
]);
}

private function isDomainVerified(string $email): bool
{
$emailDomain = substr(strrchr($email, "@"), 1);
$domain = Domain::where('domain', $emailDomain)->first();

return $domain?->verified_at !== null;
}

public function verifyMFAOTP(Request $request, GeoIP $geoIP)
{
$request->validate([
Expand Down
Loading