From 1cead5cf60691af542a27a50aba62c724bf89164 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 13 Sep 2026 04:39:56 -0600 Subject: [PATCH] Update in-app registration --- app/Auth/AppRegisterGrant.php | 113 ++++++++ app/Auth/AppRegisterTokenFactory.php | 131 +++++++++ .../Controllers/AppRegisterController.php | 273 +++++++++++++++--- config/auth.php | 1 + resources/views/auth/iar-resend.blade.php | 249 ++++++++-------- resources/views/auth/iar.blade.php | 246 ++++++++-------- 6 files changed, 727 insertions(+), 286 deletions(-) create mode 100644 app/Auth/AppRegisterGrant.php create mode 100644 app/Auth/AppRegisterTokenFactory.php diff --git a/app/Auth/AppRegisterGrant.php b/app/Auth/AppRegisterGrant.php new file mode 100644 index 000000000..bd4473e94 --- /dev/null +++ b/app/Auth/AppRegisterGrant.php @@ -0,0 +1,113 @@ +setRefreshTokenRepository($refreshTokenRepository); + $this->refreshTokenTTL = new DateInterval('P1M'); + } + + public function respondToAccessTokenRequest( + ServerRequestInterface $request, + ResponseTypeInterface $responseType, + DateInterval $accessTokenTTL + ): ResponseTypeInterface { + $client = $this->validateRegisteredClient($request); + + $userIdentifier = $this->getRequestParameter('user_id', $request); + + if ($userIdentifier === null || $userIdentifier === '') { + throw OAuthServerException::invalidRequest('user_id'); + } + + $userIdentifier = (string) $userIdentifier; + + $scopes = $this->scopeRepository->finalizeScopes( + $this->validateScopes($this->getRequestParameter('scope', $request, $this->defaultScope)), + $this->getIdentifier(), + $client, + $userIdentifier + ); + + $accessToken = $this->issueAccessToken($accessTokenTTL, $client, $userIdentifier, $scopes); + + $this->getEmitter()->emit( + new RequestAccessTokenEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request, $accessToken) + ); + + Passport::token()->newQuery()->whereKey($accessToken->getIdentifier())->update([ + 'name' => $this->getRequestParameter('name', $request) ?: $client->getName(), + ]); + + $responseType->setAccessToken($accessToken); + + $refreshToken = $this->issueRefreshToken($accessToken); + + if ($refreshToken !== null) { + $this->getEmitter()->emit( + new RequestRefreshTokenEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request, $refreshToken) + ); + $responseType->setRefreshToken($refreshToken); + } + + return $responseType; + } + + /** + * Same checks as AbstractGrant::validateClient minus the grant_types + * gate. Clients created by /api/v1/apps have no explicit grant_types + * column, so Passport computes the list and "app_register" is never in + * it. We only need: client exists, is confidential, secret matches. + */ + protected function validateRegisteredClient(ServerRequestInterface $request): ClientEntityInterface + { + [$clientId, $clientSecret] = $this->getClientCredentials($request); + + $client = $this->clientRepository->getClientEntity($clientId); + + if (! $client instanceof ClientEntityInterface || ! $client->isConfidential()) { + $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); + + throw OAuthServerException::invalidClient($request); + } + + if ( + $clientSecret === '' || + ! $this->clientRepository->validateClient($clientId, $clientSecret, $this->getIdentifier()) + ) { + $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); + + throw OAuthServerException::invalidClient($request); + } + + return $client; + } + + public function getIdentifier(): string + { + return self::IDENTIFIER; + } +} diff --git a/app/Auth/AppRegisterTokenFactory.php b/app/Auth/AppRegisterTokenFactory.php new file mode 100644 index 000000000..6dd8e3be9 --- /dev/null +++ b/app/Auth/AppRegisterTokenFactory.php @@ -0,0 +1,131 @@ + + * + * @throws OAuthServerException + */ + public function issue( + User $user, + string $clientId, + string $clientSecret, + array $scopes, + ?string $name = null + ): array { + $response = $this->server()->respondToAccessTokenRequest( + $this->createRequest($user, $clientId, $clientSecret, $scopes, $name), + app(ResponseInterface::class) + ); + + return json_decode((string) $response->getBody(), true); + } + + /** + * Cheap pre-check so the controller can reject bad client credentials + * before it creates the user. Same repository call the grant makes. + */ + public function validateClient(string $clientId, string $clientSecret): bool + { + if ($clientId === '' || $clientSecret === '') { + return false; + } + + return app(ClientRepository::class)->validateClient( + $clientId, + $clientSecret, + AppRegisterGrant::IDENTIFIER + ); + } + + /** + * @param string[] $scopes + */ + protected function createRequest( + User $user, + string $clientId, + string $clientSecret, + array $scopes, + ?string $name + ): ServerRequestInterface { + return (new PsrHttpFactory)->createRequest(Request::create(config('app.url'), 'POST', [ + 'grant_type' => AppRegisterGrant::IDENTIFIER, + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'user_id' => (string) $user->getKey(), + 'scope' => implode(' ', $scopes), + 'name' => $name, + ])); + } + + protected function server(): AuthorizationServer + { + if ($this->server) { + return $this->server; + } + + $server = new AuthorizationServer( + app(ClientRepository::class), + app(AccessTokenRepository::class), + app(ScopeRepository::class), + $this->privateKey(), + Passport::tokenEncryptionKey(app('encrypter')), + new BearerTokenResponse + ); + + $server->setDefaultScope(Passport::$defaultScope); + $server->revokeRefreshTokens(Passport::$revokeRefreshTokenAfterUse); + + $grant = new AppRegisterGrant(app(RefreshTokenRepository::class)); + $grant->setRefreshTokenTTL(Passport::refreshTokensExpireIn()); + + $server->enableGrantType($grant, Passport::tokensExpireIn()); + + return $this->server = $server; + } + + /** + * Same resolution as PassportServiceProvider::makeCryptKey('private'). + */ + protected function privateKey(): CryptKey + { + $key = str_replace('\\n', "\n", config('passport.private_key') ?? ''); + + if (! $key) { + $key = 'file://'.Passport::keyPath('oauth-private.key'); + } + + return new CryptKey($key, null, Passport::$validateKeyPermissions); + } +} diff --git a/app/Http/Controllers/AppRegisterController.php b/app/Http/Controllers/AppRegisterController.php index 2b0daf066..8a52135c5 100644 --- a/app/Http/Controllers/AppRegisterController.php +++ b/app/Http/Controllers/AppRegisterController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Auth\AppRegisterTokenFactory; use App\Mail\InAppRegisterEmailVerify; use App\Models\AppRegister; use App\Models\User; @@ -16,7 +17,9 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Str; +use Laravel\Passport\Passport; use Laravel\Passport\RefreshToken; +use League\OAuth2\Server\Exception\OAuthServerException; use Purify; class AppRegisterController extends Controller @@ -25,6 +28,28 @@ class AppRegisterController extends Controller private const VERIFY_CODE_TTL_SECONDS = 3600; + private const RESEND_MAX_USES = 5; + + /** + * Where the web steps send the browser when no redirect_uri is given. + * Keeps the original app working unchanged. + */ + private const LEGACY_REDIRECT_URI = 'pixelfed://verifyEmail'; + + private const DEFAULT_SCOPES = ['read', 'write', 'follow', 'push']; + + private const BLOCKED_REDIRECT_SCHEMES = [ + 'http', + 'https', + 'javascript', + 'data', + 'file', + 'ftp', + 'blob', + 'vbscript', + 'about', + ]; + public function index(Request $request): RedirectResponse|View { abort_unless(config('auth.in_app_registration'), 404); @@ -33,7 +58,12 @@ class AppRegisterController extends Controller return redirect('/'); } - return view('auth.iar'); + $redirectUri = $this->resolveRedirectUri($request); + + return view('auth.iar', [ + 'redirectUri' => $redirectUri, + 'resendUrl' => $this->resendUrl($redirectUri), + ]); } public function store(Request $request): RedirectResponse @@ -44,6 +74,8 @@ class AppRegisterController extends Controller return redirect('/'); } + $redirectUri = $this->resolveRedirectUri($request); + $rules = [ 'email' => 'required|email:rfc,dns,spoof,strict|unique:users,email|unique:app_registers,email', ]; @@ -55,23 +87,22 @@ class AppRegisterController extends Controller $this->validate($request, $rules); $email = strtolower($request->input('email')); - $code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); + $code = $this->generateCode(); DB::beginTransaction(); $exists = AppRegister::whereEmail($email)->count(); if ($exists) { - $errorParams = http_build_query([ + DB::rollBack(); + + return $this->appRedirect($redirectUri, [ 'status' => 'error', 'message' => 'Too many attempts, please try again later.', ]); - DB::rollBack(); - - return redirect()->away("pixelfed://verifyEmail?{$errorParams}"); } - $registration = AppRegister::create([ + AppRegister::create([ 'email' => $email, 'verify_code' => $code, 'uses' => 1, @@ -82,23 +113,20 @@ class AppRegisterController extends Controller Mail::to($email)->send(new InAppRegisterEmailVerify($code)); } catch (\Exception $e) { DB::rollBack(); - $errorParams = http_build_query([ + + return $this->appRedirect($redirectUri, [ 'status' => 'error', 'message' => 'Failed to send verification code', ]); - - return redirect()->away("pixelfed://verifyEmail?{$errorParams}"); } DB::commit(); - $queryParams = http_build_query([ - 'email' => $request->email, - 'expires_in' => 3600, + return $this->appRedirect($redirectUri, [ 'status' => 'success', + 'email' => $email, + 'expires_in' => self::VERIFY_CODE_TTL_SECONDS, ]); - - return redirect()->away("pixelfed://verifyEmail?{$queryParams}"); } public function verifyCode(Request $request): JsonResponse|RedirectResponse @@ -141,7 +169,9 @@ class AppRegisterController extends Controller return redirect('/'); } - return view('auth.iar-resend'); + return view('auth.iar-resend', [ + 'redirectUri' => $this->resolveRedirectUri($request), + ]); } public function resendVerificationStore(Request $request): RedirectResponse @@ -152,6 +182,8 @@ class AppRegisterController extends Controller return redirect('/'); } + $redirectUri = $this->resolveRedirectUri($request); + $rules = [ 'email' => 'required|email:rfc,dns,spoof,strict|unique:users,email|exists:app_registers,email', ]; @@ -163,28 +195,24 @@ class AppRegisterController extends Controller $this->validate($request, $rules); $email = strtolower($request->input('email')); - $code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); + $code = $this->generateCode(); DB::beginTransaction(); $exists = AppRegister::whereEmail($email)->first(); - if (! $exists || $exists->uses > 5) { - $errorMessage = ! $exists - ? 'Email not found' - : 'Too many attempts have been made, please contact the admins.'; + if (! $exists || $exists->uses > self::RESEND_MAX_USES) { + DB::rollBack(); - $errorParams = http_build_query([ + return $this->appRedirect($redirectUri, [ 'status' => 'error', - 'message' => $errorMessage, + 'message' => ! $exists + ? 'Email not found' + : 'Too many attempts have been made, please contact the admins.', ]); - - DB::rollBack(); - - return redirect()->away("pixelfed://verifyEmail?{$errorParams}"); } - $registration = $exists->update([ + $exists->update([ 'verify_code' => $code, 'uses' => ($exists->uses + 1), 'failed_attempts' => 0, @@ -195,23 +223,20 @@ class AppRegisterController extends Controller Mail::to($email)->send(new InAppRegisterEmailVerify($code)); } catch (\Exception $e) { DB::rollBack(); - $errorParams = http_build_query([ + + return $this->appRedirect($redirectUri, [ 'status' => 'error', 'message' => 'Failed to send verification code', ]); - - return redirect()->away("pixelfed://verifyEmail?{$errorParams}"); } DB::commit(); - $queryParams = http_build_query([ - 'email' => $request->email, - 'expires_in' => 3600, + return $this->appRedirect($redirectUri, [ 'status' => 'success', + 'email' => $email, + 'expires_in' => self::VERIFY_CODE_TTL_SECONDS, ]); - - return redirect()->away("pixelfed://verifyEmail?{$queryParams}"); } public function onboarding(Request $request): JsonResponse|RedirectResponse @@ -228,19 +253,48 @@ class AppRegisterController extends Controller 'username' => $this->validateUsernameRule(), 'name' => 'nullable|string|max:'.config('pixelfed.max_name_length'), 'password' => 'required|string|min:'.config('pixelfed.min_password_length'), + 'client_id' => 'nullable|string|max:80|required_with:client_secret', + 'client_secret' => 'nullable|string|max:255|required_with:client_id', + 'scope' => 'nullable|string|max:255', ]); $email = strtolower($request->input('email')); - $code = $request->input('verify_code'); + $code = (string) $request->input('verify_code'); $username = $request->input('username'); $name = $request->input('name'); $password = $request->input('password'); + $clientId = $request->input('client_id'); + $clientSecret = $request->input('client_secret'); + + $tokenFactory = app(AppRegisterTokenFactory::class); + $scopes = null; + + if ($clientId) { + $scopes = $this->resolveScopes($request->input('scope')); + + if ($scopes === null) { + return response()->json([ + 'status' => 'error', + 'code' => 'invalid_scope', + 'message' => 'Invalid scope.', + ], 422); + } + + if (! $tokenFactory->validateClient((string) $clientId, (string) $clientSecret)) { + return response()->json([ + 'status' => 'error', + 'code' => 'invalid_client', + 'message' => 'Invalid client credentials.', + ], 401); + } + } - $result = $this->checkVerificationCode($email, (string) $code); + $result = $this->checkVerificationCode($email, $code); if ($result['locked']) { return response()->json([ 'status' => 'error', + 'code' => 'locked', 'message' => 'Too many verification attempts. Please request a new code.', ], 429); } @@ -248,12 +302,13 @@ class AppRegisterController extends Controller if (! $result['valid']) { return response()->json([ 'status' => 'error', + 'code' => 'invalid_code', 'message' => 'Invalid or expired verification code.', ]); } $user = User::create([ - 'name' => Purify::clean($name), + 'name' => $name ? Purify::clean($name) : null, 'username' => $username, 'email' => $email, 'password' => Hash::make($password), @@ -263,7 +318,48 @@ class AppRegisterController extends Controller ]); $user->refresh(); - $token = $user->createToken('Pixelfed App', ['read', 'write', 'follow', 'push']); + + AppRegister::whereEmail($email)->delete(); + + if (! $clientId) { + return $this->legacyOnboardingResponse($user); + } + + try { + $tokens = $tokenFactory->issue($user, (string) $clientId, (string) $clientSecret, $scopes); + } catch (OAuthServerException $e) { + return response()->json([ + 'status' => 'error', + 'code' => 'account_created_token_failed', + 'message' => 'Your account was created but we could not sign you in automatically. Please sign in with your email and password.', + ]); + } + + return response()->json([ + 'status' => 'success', + 'domain' => config('pixelfed.domain.app'), + 'token_type' => 'Bearer', + 'access_token' => $tokens['access_token'], + 'refresh_token' => $tokens['refresh_token'] ?? null, + 'expires_in' => $tokens['expires_in'], + 'scope' => $scopes, + 'client_id' => (string) $clientId, + 'user' => [ + 'pid' => (string) $user->profile_id, + 'username' => $user->username, + ], + 'account' => AccountService::get($user->profile_id, true), + ]); + } + + /** + * Original personal-access-token path, kept byte-for-byte in behaviour + * for the previous app. Note the refresh_token here is a raw row id and + * is not usable with the refresh_token grant. + */ + protected function legacyOnboardingResponse(User $user): JsonResponse + { + $token = $user->createToken('Pixelfed App', self::DEFAULT_SCOPES); $tokenModel = $token->token; $clientId = $tokenModel->client_id; $clientSecret = DB::table('oauth_clients')->where('id', $clientId)->value('secret'); @@ -276,7 +372,6 @@ class AppRegisterController extends Controller $expiresAt = $tokenModel->expires_at ?? now()->addDays(config('instance.oauth.token_expiration', 356)); $expiresIn = now()->diffInSeconds($expiresAt); - AppRegister::whereEmail($email)->delete(); return response()->json([ 'status' => 'success', @@ -287,7 +382,7 @@ class AppRegisterController extends Controller 'refresh_token' => $refreshToken->id, 'client_id' => $clientId, 'client_secret' => $clientSecret, - 'scope' => ['read', 'write', 'follow', 'push'], + 'scope' => self::DEFAULT_SCOPES, 'user' => [ 'pid' => (string) $user->profile_id, 'username' => $user->username, @@ -307,6 +402,100 @@ class AppRegisterController extends Controller ]; } + protected function generateCode(): string + { + return str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + } + + /** + * Space or plus separated scope string from the app. Returns null when + * any requested scope is unknown to Passport. Empty means defaults. + * + * @return string[]|null + */ + protected function resolveScopes(?string $scope): ?array + { + $scopes = collect(explode(' ', str_replace('+', ' ', trim((string) $scope)))) + ->map(fn ($s) => trim($s)) + ->filter() + ->unique() + ->values() + ->all(); + + if (! count($scopes)) { + return self::DEFAULT_SCOPES; + } + + foreach ($scopes as $s) { + if ($s === '*' || ! Passport::hasScope($s)) { + return null; + } + } + + return $scopes; + } + + /** + * Validates the optional redirect_uri the app passes to the web steps. + * Only custom schemes on the configured allowlist are accepted, so this + * can never become an open redirect to a web origin. Query and fragment + * are stripped since we append our own params. + */ + protected function resolveRedirectUri(Request $request): string + { + $uri = $request->input('redirect_uri'); + + if (! is_string($uri) || trim($uri) === '') { + return self::LEGACY_REDIRECT_URI; + } + + $uri = trim($uri); + + abort_if( + strlen($uri) > 512 || preg_match('/[\s\x00-\x1F\x7F]/', $uri), + 400, + 'Invalid redirect_uri.' + ); + + $scheme = strtolower((string) parse_url($uri, PHP_URL_SCHEME)); + + abort_if( + $scheme === '' || + in_array($scheme, self::BLOCKED_REDIRECT_SCHEMES, true) || + ! in_array($scheme, $this->allowedRedirectSchemes(), true), + 400, + 'Invalid redirect_uri.' + ); + + return preg_replace('/[?#].*$/', '', $uri); + } + + /** + * @return string[] + */ + protected function allowedRedirectSchemes(): array + { + return collect(explode(',', (string) config('auth.in_app_registration_redirect_schemes', 'pixelfed'))) + ->map(fn ($s) => strtolower(trim($s))) + ->filter() + ->values() + ->all(); + } + + protected function resendUrl(string $redirectUri): string + { + if ($redirectUri === self::LEGACY_REDIRECT_URI) { + return '/i/app-email-resend'; + } + + return '/i/app-email-resend?'.http_build_query(['redirect_uri' => $redirectUri]); + } + + protected function appRedirect(string $redirectUri, array $params): RedirectResponse + { + return redirect()->away($redirectUri.'?'.http_build_query($params)); + } + protected function checkVerificationCode(string $email, string $code): array { return DB::transaction(function () use ($email, $code) { diff --git a/config/auth.php b/config/auth.php index b5b8d402d..7aad20786 100644 --- a/config/auth.php +++ b/config/auth.php @@ -121,6 +121,7 @@ return [ ], 'in_app_registration' => (bool) env('APP_REGISTER', true), + 'in_app_registration_redirect_schemes' => env('IN_APP_REGISTRATION_REDIRECT_SCHEMES', 'pixelfed'), /* |-------------------------------------------------------------------------- diff --git a/resources/views/auth/iar-resend.blade.php b/resources/views/auth/iar-resend.blade.php index b486941d0..76f9b7706 100644 --- a/resources/views/auth/iar-resend.blade.php +++ b/resources/views/auth/iar-resend.blade.php @@ -1,146 +1,149 @@ @extends('layouts.blank') @section('content') -
-
-
-
- -
+
+
+
+
+ +
-
-
-

Resend Verification

-

Enter your email so we can send another verification code via email

- -
- @csrf - -
- - filled('email')) - value="{{rawurldecode(request()->input('email'))}}" - @endif - > - @error('email') -
{{ $message }}
- @enderror -
- - @if((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) -
- {!! Captcha::display() !!} -
+
+
+

Resend Verification

+

Enter your email so we can send another verification code via email

+ + + @csrf + + +
+ + filled('email')) + value="{{rawurldecode(request()->input('email'))}}" @endif + > + @error('email') +
{{ $message }}
+ @enderror +
- - - - @if ($errors->any()) -
-

Click here to send a new request.

+ @if((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) +
+ {!! Captcha::display() !!}
@endif + + + + + @if ($errors->any()) +
+

Click here to send a new request.

+ @endif
+
@endsection @push('styles') - + } + @endpush diff --git a/resources/views/auth/iar.blade.php b/resources/views/auth/iar.blade.php index 7382b390e..22c4bf8e5 100644 --- a/resources/views/auth/iar.blade.php +++ b/resources/views/auth/iar.blade.php @@ -1,142 +1,146 @@ @extends('layouts.blank') @section('content') -
-
-
-
- -
+
+
+
+
+ +
+ +
+
+

Join Pixelfed

+

Enter Your Email

+ +
+ @csrf + + +
+ + + @error('email') +
{{ $message }}
+ @enderror +
-
-
-

Join Pixelfed

-

Enter Your Email

- - - @csrf - -
- - - @error('email') -
{{ $message }}
- @enderror -
- - @if((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) -
- {!! Captcha::display() !!} -
- @endif - - - - - @if ($errors->any()) -
-

If you need to resend the email verification, click here.

+ @if((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) +
+ {!! Captcha::display() !!}
@endif + + + + + @if ($errors->any()) +
+

If you need to resend the email verification, click here.

+ @endif
+
@endsection @push('styles') - + } + @endpush