diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a71e50..bc815f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/TODO.md b/TODO.md index d1c6d89..1004491 100644 --- a/TODO.md +++ b/TODO.md @@ -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). --- diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e2a74e9..4c28401 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -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 diff --git a/src/Exceptions/InvalidInvitationException.php b/src/Exceptions/InvalidInvitationException.php new file mode 100644 index 0000000..de09e15 --- /dev/null +++ b/src/Exceptions/InvalidInvitationException.php @@ -0,0 +1,10 @@ +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); @@ -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; - } } diff --git a/src/Http/Controllers/Auth/OAuthController.php b/src/Http/Controllers/Auth/OAuthController.php index 911e160..58635d7 100644 --- a/src/Http/Controllers/Auth/OAuthController.php +++ b/src/Http/Controllers/Auth/OAuthController.php @@ -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; @@ -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')); } } @@ -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; - } } diff --git a/src/Http/Controllers/Auth/UserAuthApiController.php b/src/Http/Controllers/Auth/UserAuthApiController.php index bf377eb..fd7508f 100644 --- a/src/Http/Controllers/Auth/UserAuthApiController.php +++ b/src/Http/Controllers/Auth/UserAuthApiController.php @@ -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); @@ -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.', @@ -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([ diff --git a/src/Http/Controllers/Auth/UserAuthController.php b/src/Http/Controllers/Auth/UserAuthController.php index a2f2c17..4b5b775 100644 --- a/src/Http/Controllers/Auth/UserAuthController.php +++ b/src/Http/Controllers/Auth/UserAuthController.php @@ -11,9 +11,9 @@ use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Session; 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\Http\Requests\Auth\LoginRequest; use Ssntpl\Neev\Mail\EmailOTP; @@ -21,12 +21,12 @@ use Ssntpl\Neev\Mail\VerifyUserEmail; 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\Contracts\IdentityProviderOwnerInterface; use Ssntpl\Neev\Services\AuthService; use Ssntpl\Neev\Services\GeoIP; +use Ssntpl\Neev\Services\RegistrationService; use Ssntpl\Neev\Services\TenantResolver; use Illuminate\Support\Facades\URL; @@ -68,63 +68,14 @@ public function registerCreate(Request $request) */ public function registerStore(LoginRequest $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'); - } - - $request->validate($validationRules); + $request->validate(app(RegistrationService::class)->rules()); try { - 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; - } - - $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 back()->withErrors(['message' => 'Invalid or expired invitation link.']); - } - - $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 = $this->createUserTeam($user, $request->email); - $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, + ); $this->auth->login($request, $geoIP, $user, LoginAttempt::Password); @@ -134,8 +85,9 @@ public function registerStore(LoginRequest $request, GeoIP $geoIP) } return redirect(config('neev.home')); + } catch (InvalidInvitationException $e) { + return back()->withErrors(['message' => $e->getMessage()]); } catch (Exception $e) { - DB::rollBack(); Log::error($e); return back()->withErrors(['message' => 'Unable to register user.']); } @@ -186,15 +138,7 @@ public function loginPassword(LoginRequest $request) ]); } - $isDomainFederated = false; - if (config('neev.team')) { - $emailDomain = substr(strrchr($request->email, "@"), 1); - - $domain = Domain::where('domain', $emailDomain)->first(); - if ($domain?->verified_at) { - $isDomainFederated = true; - } - } + $isDomainFederated = config('neev.team') && Domain::isVerifiedForEmail($request->email); $loginOptions = []; if (count($user->passkeys) > 0) { @@ -630,20 +574,4 @@ public function verifyMFAOTPStore(LoginRequest $request, GeoIP $geoIP) return back()->withErrors(['message' => 'Code is invalid']); } - private function isDomainVerified(string $email): bool - { - $emailDomain = substr(strrchr($email, "@"), 1); - $domain = Domain::where('domain', $emailDomain)->first(); - return $domain?->verified_at !== null; - } - - private function createUserTeam(User $user, ?string $email = null): Team - { - return Team::model()->forceCreate([ - 'name' => explode(' ', $user->name, 2)[0] . "'s Team", - 'user_id' => $user->id, - 'is_public' => false, - 'activated_at' => now(), - ]); - } } diff --git a/src/Models/Domain.php b/src/Models/Domain.php index 5bbb2dd..2db380d 100644 --- a/src/Models/Domain.php +++ b/src/Models/Domain.php @@ -58,6 +58,18 @@ public function owner() return $this->morphTo(); } + /** + * Whether the email's domain has been claimed and DNS-verified by + * a team/tenant (used to skip personal-team auto-creation for + * federated domains during registration). + */ + public static function isVerifiedForEmail(string $email): bool + { + $emailDomain = substr(strrchr($email, '@'), 1); + + return static::where('domain', $emailDomain)->first()?->verified_at !== null; + } + public function rules() { return $this->hasMany(DomainRule::class); diff --git a/src/Services/MembershipService.php b/src/Services/MembershipService.php deleted file mode 100644 index 898369b..0000000 --- a/src/Services/MembershipService.php +++ /dev/null @@ -1,23 +0,0 @@ -hasMember($user); - } -} diff --git a/src/Services/RegistrationService.php b/src/Services/RegistrationService.php new file mode 100644 index 0000000..0160629 --- /dev/null +++ b/src/Services/RegistrationService.php @@ -0,0 +1,152 @@ + 'required|string|max:255', + 'email' => ['required', 'string', 'email', 'max:255', User::uniqueEmailRule()], + 'password' => config('neev.password'), + ]; + + if (config('neev.support_username')) { + $rules['username'] = config('neev.username'); + } + + return $rules; + } + + /** + * Register a user with a password, honouring team invitations and + * federated-domain team-creation rules. Owns the transaction and + * fires Registered after commit. + * + * @param array{name: string, email: string, password: string, username?: string} $data + * @throws InvalidInvitationException When the invitation id/hash pair is invalid. + */ + public function register(array $data, $invitationId = null, ?string $hash = null): User + { + $userData = [ + 'name' => $data['name'], + 'email' => $data['email'], + 'password' => $data['password'], + 'password_changed_at' => now(), + ]; + + if (config('neev.support_username') && isset($data['username'])) { + $userData['username'] = $data['username']; + } + + $user = DB::transaction(function () use ($userData, $data, $invitationId, $hash) { + $user = User::model()->forceCreate($userData); + $user = User::model()->find($user->id); + + if (config('neev.team')) { + if ($invitationId) { + $this->acceptInvitation($user, $invitationId, $hash); + } elseif (!Domain::isVerifiedForEmail($data['email'])) { + $this->createDefaultTeam($user)->addMember($user); + } + } + + return $user; + }); + + event(new Registered($user)); + + return $user; + } + + /** + * Register a user from an OAuth/social profile: no password, email + * pre-verified by the provider. + */ + public function registerViaOAuth(string $name, string $email): User + { + $userData = [ + 'name' => $name, + 'email' => $email, + 'email_verified_at' => now(), + ]; + + if (config('neev.support_username')) { + $userData['username'] = $this->uniqueUsernameFrom($email); + } + + $user = DB::transaction(function () use ($userData, $email) { + $user = User::model()->forceCreate($userData); + $user = User::model()->find($user->id); + + if (config('neev.team') && !Domain::isVerifiedForEmail($email)) { + $this->createDefaultTeam($user)->addMember($user); + } + + return $user; + }); + + event(new Registered($user)); + + return $user; + } + + /** + * @throws InvalidInvitationException + */ + protected function acceptInvitation(User $user, $invitationId, ?string $hash): void + { + $invitation = TeamInvitation::find($invitationId); + if (!$invitation || !hash_equals(sha1($invitation->email), (string) $hash)) { + throw new InvalidInvitationException(); + } + + // The invitation was sent to this address, so it is proven owned. + $user->markEmailAsVerified(); + + $team = $invitation->team; + $team->users()->attach($user, ['joined' => true]); + if ($invitation->role) { + $user->assignRole($invitation->role, $team); + } + $invitation->delete(); + } + + protected function createDefaultTeam(User $user): Team + { + return Team::model()->forceCreate([ + 'name' => explode(' ', $user->name, 2)[0] . "'s Team", + 'user_id' => $user->id, + 'is_public' => false, + 'activated_at' => now(), + ]); + } + + protected function uniqueUsernameFrom(string $email): string + { + $base = explode('@', $email)[0]; + $username = $base; + while (User::getModel()->where('username', $username)->first()) { + $username = $base . '_' . Str::random(4); + } + + return $username; + } +}