first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
<?php
namespace Common\Auth\Actions;
use App\Models\User;
use Common\Auth\Events\UserCreated;
use Common\Auth\Permissions\Traits\SyncsPermissions;
use Common\Auth\Roles\Role;
use Illuminate\Support\Arr;
class CreateUser
{
use SyncsPermissions;
public function execute(array $params): User
{
if (
!settings('require_email_confirmation') &&
!array_key_exists('email_verified_at', $params)
) {
$params['email_verified_at'] = now();
}
$geoData = geoip(getIp());
$params['language'] = $params['language'] ?? config('app.locale');
$params['country'] =
$params['country'] ?? ($geoData['iso_code'] ?? null);
$params['timezone'] =
$params['timezone'] ?? ($geoData['timezone'] ?? null);
$user = User::create(Arr::except($params, ['roles', 'permissions']));
if (array_key_exists('roles', $params)) {
$user->roles()->attach($params['roles']);
}
// if no roles were attached, assign default role
if ($user->roles()->count() === 0) {
$defaultRole = app(Role::class)->getDefaultRole();
if ($defaultRole) {
$user->roles()->attach($defaultRole->id);
}
}
if (array_key_exists('permissions', $params)) {
$this->syncPermissions($user, $params['permissions']);
}
event(new UserCreated($user, $params));
return $user;
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Common\Auth\Actions;
use App\Models\User;
use Common\Auth\ActiveSession;
use Common\Auth\Ban;
use Common\Auth\Events\UsersDeleted;
use Common\Billing\Subscription;
use Common\Csv\CsvExport;
use Common\Domains\Actions\DeleteCustomDomains;
use Common\Domains\CustomDomain;
use Common\Files\Actions\Deletion\PermanentlyDeleteEntries;
use Common\Pages\CustomPage;
class DeleteUsers
{
public function execute(array $ids): int
{
$users = User::whereIn('id', $ids)->get();
$users->each(function (User $user) {
$user->social_profiles()->delete();
$user->roles()->detach();
$user->notifications()->delete();
$user->permissions()->detach();
if ($user->subscribed()) {
$user->subscriptions->each(function (
Subscription $subscription,
) {
$subscription->cancelAndDelete();
});
}
$user->delete();
$entryIds = $user
->entries(['owner' => true])
->pluck('file_entries.id');
app(PermanentlyDeleteEntries::class)->execute($entryIds);
});
// delete domains
$domainIds = app(CustomDomain::class)
->whereIn('user_id', $ids)
->pluck('id');
app(DeleteCustomDomains::class)->execute($domainIds->toArray());
// delete custom pages
CustomPage::whereIn('user_id', $ids)->delete();
// delete sessions
ActiveSession::whereIn('user_id', $ids)->delete();
// csv exports
CsvExport::whereIn('user_id', $ids)->delete();
// bans
Ban::where('bannable_type', User::MODEL_TYPE)
->whereIn('bannable_id', $ids)
->delete();
event(new UsersDeleted($users));
return $users->count();
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Common\Auth\Actions;
use App\Models\User;
use Common\Database\Datasource\Datasource;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Arr;
class PaginateUsers
{
public function execute(array $params): AbstractPaginator
{
$query = User::with(['roles', 'permissions']);
if ($roleId = Arr::get($params, 'roleId')) {
$relation = $query->getModel()->roles();
$query
->leftJoin(
$relation->getTable(),
$relation->getQualifiedParentKeyName(),
'=',
$relation->getQualifiedForeignPivotKeyName(),
)
->where(
$relation->getQualifiedRelatedPivotKeyName(),
'=',
$roleId,
);
$query->select(['users.*', 'user_role.created_at as created_at']);
}
if ($roleName = Arr::get($params, 'roleName')) {
$query->whereHas(
'roles',
fn(Builder $q) => $q->where('roles.name', $roleName),
);
}
if ($permission = Arr::get($params, 'permission')) {
$query
->whereHas(
'permissions',
fn(Builder $query) => $query
->where('name', $permission)
->orWhere('name', 'admin'),
)
->orWhereHas(
'roles',
fn(Builder $query) => $query->whereHas(
'permissions',
fn(Builder $query) => $query
->where('name', $permission)
->orWhere('name', 'admin'),
),
);
}
return (new Datasource($query, $params))->paginate();
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Common\Auth\Actions;
use App\Models\User;
use Common\Auth\Permissions\Traits\SyncsPermissions;
use Illuminate\Support\Arr;
class UpdateUser
{
use SyncsPermissions;
public function execute(User $user, array $params): User
{
$user->fill(Arr::except($params, ['roles', 'permissions']))->save();
// make sure roles and permission are not removed
// if they are not specified at all in params
if (array_key_exists('roles', $params)) {
$user->roles()->sync($params['roles']);
}
if (array_key_exists('permissions', $params)) {
$this->syncPermissions($user, Arr::get($params, 'permissions'));
}
return $user->load(['roles', 'permissions']);
}
}

17
common/Auth/ActiveSession.php Executable file
View File

@@ -0,0 +1,17 @@
<?php
namespace Common\Auth;
use Illuminate\Database\Eloquent\Model;
class ActiveSession extends Model
{
protected $guarded = ['id'];
const MODEL_TYPE = 'active_session';
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
}

32
common/Auth/Ban.php Executable file
View File

@@ -0,0 +1,32 @@
<?php
namespace Common\Auth;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Ban extends Model
{
protected $guarded = ['id'];
protected $casts = [
'expired_at' => 'datetime',
];
const MODEL_TYPE = 'ban';
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
protected static function booted(): void
{
static::created(function (Ban $ban) {});
}
public function createdBy(): MorphTo
{
return $this->morphTo('created_by');
}
}

485
common/Auth/BaseUser.php Executable file
View File

@@ -0,0 +1,485 @@
<?php namespace Common\Auth;
use App\Models\User;
use Common\Auth\Notifications\VerifyEmailWithOtp;
use Common\Auth\Permissions\Permission;
use Common\Auth\Permissions\Traits\HasPermissionsRelation;
use Common\Auth\Roles\Role;
use Common\Auth\Traits\HasAvatarAttribute;
use Common\Auth\Traits\HasDisplayNameAttribute;
use Common\Billing\Billable;
use Common\Billing\Models\Product;
use Common\Core\BaseModel;
use Common\Files\FileEntry;
use Common\Files\FileEntryPivot;
use Common\Files\Traits\SetsAvailableSpaceAttribute;
use Common\Notifications\NotificationSubscription;
use Illuminate\Auth\Authenticatable;
use Illuminate\Auth\MustVerifyEmail;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Foundation\Auth\Access\Authorizable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Scout\Searchable;
abstract class BaseUser extends BaseModel implements
HasLocalePreference,
AuthenticatableContract,
AuthorizableContract,
CanResetPasswordContract
{
use Searchable,
Notifiable,
Billable,
TwoFactorAuthenticatable,
SetsAvailableSpaceAttribute,
HasPermissionsRelation,
HasAvatarAttribute,
HasDisplayNameAttribute,
Authenticatable,
Authorizable,
CanResetPassword,
MustVerifyEmail;
const MODEL_TYPE = 'user';
protected $guarded = ['id'];
protected $hidden = [
'password',
'remember_token',
'pivot',
'legacy_permissions',
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
];
protected $casts = [
'id' => 'integer',
'available_space' => 'integer',
'email_verified_at' => 'datetime',
'unread_notifications_count' => 'integer',
];
protected $appends = ['display_name', 'has_password', 'model_type'];
protected bool $billingEnabled = true;
protected $gravatarSize;
public function preferredLocale()
{
return $this->language;
}
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->billingEnabled = (bool) settings('billing.enable');
}
public function toArray(bool $showAll = false): array
{
if (
(!$showAll && !Auth::id()) ||
(Auth::id() !== $this->id &&
!Auth::user()?->hasPermission('users.update'))
) {
$this->hidden = array_merge($this->hidden, [
'first_name',
'last_name',
'avatar_url',
'gender',
'email',
'card_brand',
'has_password',
'confirmed',
'stripe_id',
'roles',
'permissions',
'card_last_four',
'created_at',
'updated_at',
'available_space',
'email_verified_at',
'timezone',
'confirmation_code',
'subscriptions',
]);
}
return parent::toArray();
}
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class, 'user_role');
}
public function routeNotificationForSlack()
{
return config('services.slack.webhook_url');
}
public function scopeWhereNeedsNotificationFor(
Builder $query,
string $notifId,
) {
return $query->whereHas('notificationSubscriptions', function (
Builder $builder,
) use ($notifId) {
if (Str::contains($notifId, '*')) {
return $builder->where(
'notif_id',
'like',
str_replace('*', '%', $notifId),
);
} else {
return $builder->where('notif_id', $notifId);
}
});
}
public function notificationSubscriptions(): HasMany
{
return $this->hasMany(NotificationSubscription::class);
}
public function entries(array $options = ['owner' => true]): BelongsToMany
{
$query = $this->morphToMany(
FileEntry::class,
'model',
'file_entry_models',
'model_id',
'file_entry_id',
)
->using(FileEntryPivot::class)
->withPivot('owner', 'permissions');
if (Arr::get($options, 'owner')) {
$query->wherePivot('owner', true);
}
return $query
->withTimestamps()
->orderBy('file_entry_models.created_at', 'asc');
}
public function activeSessions(): HasMany
{
return $this->hasMany(ActiveSession::class);
}
public function lastLogin(): HasOne
{
return $this->hasOne(ActiveSession::class)
->latest()
->select(['id', 'user_id', 'session_id', 'created_at']);
}
public function followedUsers(): BelongsToMany
{
return $this->belongsToMany(
User::class,
'follows',
'follower_id',
'followed_id',
)->compact();
}
public function followers(): BelongsToMany
{
return $this->belongsToMany(
User::class,
'follows',
'followed_id',
'follower_id',
)->compact();
}
public function social_profiles(): HasMany
{
return $this->hasMany(SocialProfile::class);
}
public function bans(): MorphMany
{
return $this->morphMany(Ban::class, 'bannable');
}
/**
* Check if user has a password set.
*/
public function getHasPasswordAttribute(): bool
{
return isset($this->attributes['password']) &&
$this->attributes['password'];
}
protected function password(): Attribute
{
return Attribute::make(
set: function ($value) {
if (!$value) {
return null;
}
if (Hash::isHashed($value)) {
return $value;
}
return Hash::make($value);
},
);
}
protected function availableSpace(): Attribute
{
return Attribute::make(
set: fn($value) => !is_null($value) ? (int) $value : null,
);
}
protected function otpCodes(): HasMany
{
return $this->hasMany(OtpCode::class);
}
public function emailVerificationOtpIsValid(string $code): bool
{
$otp = $this->otpCodes()
->where('type', OtpCode::TYPE_EMAIL_VERIFICATION)
->first();
if (!$otp || $otp->code !== $code || $otp->isExpired()) {
return false;
}
return true;
}
protected function emailVerifiedAt(): Attribute
{
return Attribute::make(
set: function ($value) {
if ($value === true) {
return now();
} elseif ($value === false) {
return null;
}
return $value;
},
);
}
public function sendEmailVerificationNotification(): void
{
$otp = OtpCode::createForEmailVerification($this->id);
$this->notify(new VerifyEmailWithOtp($otp->code));
}
public function markEmailAsVerified(): bool
{
$this->otpCodes()
->where('type', OtpCode::TYPE_EMAIL_VERIFICATION)
->delete();
return $this->forceFill([
'email_verified_at' => $this->freshTimestamp(),
])->save();
}
public function loadPermissions($force = false): self
{
if (!$force && $this->relationLoaded('permissions')) {
return $this;
}
$query = Permission::join(
'permissionables',
'permissions.id',
'permissionables.permission_id',
);
// Might have a guest user. In this case user ID will be -1,
// but we still want to load guest role permissions below
if ($this->exists) {
$query->where([
'permissionable_id' => $this->id,
'permissionable_type' => $this->getMorphClass(),
]);
}
if ($this->roles->pluck('id')->isNotEmpty()) {
$query->orWhere(function (Builder $builder) {
return $builder
->whereIn('permissionable_id', $this->roles->pluck('id'))
->where(
'permissionable_type',
$this->roles->first()->getMorphClass(),
);
});
}
if ($this->exists && ($plan = $this->getSubscriptionProduct())) {
$query->orWhere(function (Builder $builder) use ($plan) {
return $builder
->where('permissionable_id', $plan->id)
->where('permissionable_type', $plan->getMorphClass());
});
}
$permissions = $query
->select([
'permissions.id',
'name',
'permissionables.restrictions',
'permissionable_type',
])
->get()
->sortBy(function ($value) {
if ($value['permissionable_type'] === $this->getMorphClass()) {
return 1;
} elseif (
$value['permissionable_type'] === Product::MODEL_TYPE
) {
return 2;
} else {
return 3;
}
})
->groupBy('id')
// merge restrictions from all permissions
->map(function (Collection $group) {
return $group->reduce(function (
Permission $carry,
Permission $permission,
) {
return $carry->mergeRestrictions($permission);
}, $group[0]);
});
$this->setRelation('permissions', $permissions->values());
return $this;
}
public function getSubscriptionProduct(): ?Product
{
if (!$this->billingEnabled) {
return null;
}
$subscription = $this->subscriptions->first();
if ($subscription && $subscription->valid()) {
return $subscription->product;
} else {
return Product::where('free', true)->first();
}
}
public function scopeCompact(Builder $query): Builder
{
return $query->select(
'users.id',
'users.avatar',
'users.email',
'users.first_name',
'users.last_name',
'users.username',
);
}
public function sendPasswordResetNotification(mixed $token)
{
ResetPassword::$createUrlCallback = function ($user, $token) {
return url("password/reset/$token");
};
$this->notify(new ResetPassword($token));
}
public static function findAdmin(): ?self
{
return (new static())
->newQuery()
->whereHas('permissions', function (Builder $query) {
$query->where('name', 'admin');
})
->first();
}
public function refreshApiToken($tokenName): string
{
$this->tokens()
->where('name', $tokenName)
->delete();
$newToken = $this->createToken($tokenName);
$this->withAccessToken($newToken->accessToken);
return $newToken->plainTextToken;
}
public function isBanned(): bool
{
if (!$this->getAttributeValue('banned_at')) {
return false;
}
$bannedUntil = $this->bans->first()->expired_at;
return !$bannedUntil || $bannedUntil->isFuture();
}
public function resolveRouteBinding($value, $field = null): ?self
{
if ($value === 'me') {
$value = Auth::id();
}
return $this->where('id', $value)->firstOrFail();
}
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'username' => $this->username,
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'email' => $this->email,
'created_at' => $this->created_at->timestamp ?? '_null',
'updated_at' => $this->updated_at->timestamp ?? '_null',
];
}
public static function filterableFields(): array
{
return ['id', 'created_at', 'updated_at'];
}
public function toNormalizedArray(): array
{
return [
'id' => $this->id,
'name' => $this->display_name,
'description' => $this->email,
'image' => $this->avatar,
'model_type' => self::MODEL_TYPE,
];
}
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Common\Auth\Commands;
use Common\Auth\Ban;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
class DeleteExpiredBansCommand extends Command
{
protected $signature = 'bans:deleteExpired';
protected $description = 'Unban users whose ban date has expired.';
public function handle(): int
{
$bans = Ban::query()
->where('expired_at', '<=', Carbon::now()->format('Y-m-d H:i:s'))
->get();
$bans->each(function ($ban) {
$ban->created_by->fill(['banned_at' => null])->save();
$ban->delete();
});
$this->info("Unbanned {$bans->count()} users.");
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Common\Auth\Commands;
use Common\Auth\OtpCode;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
class DeleteExpiredOtpCodesCommand extends Command
{
protected $signature = 'otp:deleteExpired';
protected $description = 'Delete one time passwords that have expired.';
public function handle(): int
{
OtpCode::query()
->where('expires_at', '<', Carbon::now())
->delete();
$this->info('Expired OTP codes have been deleted.');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Common\Auth\Controllers;
use Auth;
use Common\Core\BaseController;
use Illuminate\Http\Request;
class AccessTokenController extends BaseController
{
public function __construct(protected Request $request)
{
$this->middleware(['auth']);
}
public function store()
{
$this->validate($this->request, [
'tokenName' => 'required|string|min:3|max:100',
]);
$token = Auth::user()->createToken($this->request->get('tokenName'));
return $this->success([
'token' => $token->accessToken,
'plainTextToken' => $token->plainTextToken,
]);
}
public function destroy(string $tokenId)
{
Auth::user()
->tokens()
->where('id', $tokenId)
->delete();
return $this->success();
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Common\Auth\Controllers;
use App\Models\User;
use Common\Core\BaseController;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
class BanController extends BaseController
{
public function store(User $user)
{
$this->authorize('destroy', [User::class, [$user->id]]);
if ($user->hasPermission('admin')) {
abort(403, 'Admin users can\'t be suspended');
}
$data = $this->validate(request(), [
'ban_until' => 'nullable|date|after:now',
'comment' => 'nullable|string|max:255',
'permanent' => 'boolean',
]);
$user->bans()->create([
'expired_at' => $data['permanent']
? null
: Arr::get($data, 'ban_until'),
'comment' => Arr::get($data, 'comment'),
'created_by_type' => User::MODEL_TYPE,
'created_by_id' => Auth::id(),
]);
$user->fill(['banned_at' => now()])->save();
return $this->success(['user' => $user]);
}
public function destroy(User $user)
{
$this->authorize('destroy', [User::class, [$user->id]]);
$user->bans()->delete();
$user->fill(['banned_at' => null])->save();
return $this->success(['user' => $user]);
}
}

View File

@@ -0,0 +1,45 @@
<?php namespace Common\Auth\Controllers;
use App\Models\User;
use Auth;
use Common\Core\BaseController;
class EmailVerificationController extends BaseController
{
public function __construct()
{
$this->middleware('auth');
}
public function validateOtp()
{
$code = request('code');
$user = Auth::user();
if (!$code || !$user->emailVerificationOtpIsValid($code)) {
$msg = __(
'The security code you entered is invalid or has expired',
);
return $this->error($msg, [
'code' => $msg,
]);
}
$user->markEmailAsVerified();
return $this->success();
}
public function resendVerificationEmail()
{
$data = $this->validate(request(), ['email' => 'required|email']);
$user = User::where('email', $data['email'])->firstOrFail();
$this->authorize('update', $user);
$user->sendEmailVerificationNotification();
return $this->success();
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Common\Auth\Controllers;
use Common\Auth\Fortify\ValidateLoginCredentials;
use Common\Core\BaseController;
use Common\Core\Bootstrap\MobileBootstrapData;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use Laravel\Fortify\Contracts\EmailVerificationNotificationSentResponse;
use Laravel\Fortify\Contracts\RegisterResponse;
use Laravel\Fortify\Fortify;
class MobileAuthController extends BaseController
{
public function login(Request $request)
{
$this->validate($request, [
Fortify::username() => 'required|string',
'password' => 'required|string',
'token_name' => 'required|string|min:3|max:100',
]);
$validator = app(ValidateLoginCredentials::class);
$user = $validator->execute($request);
if (!$user) {
$validator->throwFailedAuthenticationException(
$request,
trans('auth.failed'),
);
}
if (settings('single_device_login')) {
Auth::logoutOtherDevices($request->get('password'));
}
Auth::login($user);
$bootstrapData = app(MobileBootstrapData::class)
->init()
->refreshToken($request->get('token_name'))
->get();
return $this->success($bootstrapData);
}
public function register(
Request $request,
CreatesNewUsers $creator,
): RegisterResponse {
event(new Registered(($user = $creator->create($request->all()))));
Auth::login($user);
return app(RegisterResponse::class);
}
public function sendEmailVerificationNotification()
{
$this->middleware('auth');
if (
request()
->user()
->hasVerifiedEmail()
) {
return request()->wantsJson()
? new JsonResponse('', 204)
: redirect()->intended(
Fortify::redirects('email-verification'),
);
}
request()
->user()
->sendEmailVerificationNotification();
return app(EmailVerificationNotificationSentResponse::class);
}
}

View File

@@ -0,0 +1,166 @@
<?php namespace Common\Auth\Controllers;
use App\Models\User;
use Common\Auth\Oauth;
use Common\Core\BaseController;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Session;
class SocialAuthController extends BaseController
{
public function __construct(protected Oauth $oauth)
{
$this->middleware('auth', [
'only' => ['connect', 'disconnect'],
]);
$this->middleware('guest', [
'only' => ['login'],
]);
}
/**
* Connect specified social account to currently logged-in user.
*/
public function connect(string $provider)
{
if (!settings("social.$provider.enable")) {
abort(403);
}
return $this->oauth->redirect($provider);
}
/**
* Handles case where user is trying to log in with social account whose email
* already exists in database. Request password for local account in that case.
*/
public function connectWithPassword(): JsonResponse
{
// get data for this social login persisted in session
$data = $this->oauth->getPersistedData();
if (!$data) {
return $this->error(__('There was an issue. Please try again.'));
}
if (
!request()->has('password') ||
!Auth::validate([
'email' => $data['profile']->email,
'password' => request('password'),
])
) {
return $this->error(__('Specified credentials are not valid'), [
'password' => __('This password is not correct.'),
]);
}
return $this->success($this->oauth->createUserFromOAuthData($data));
}
public function retrieveProfile(string $providerName)
{
return $this->oauth->retrieveProfileOnly($providerName);
}
/**
* Disconnect specified social account from currently logged-in user.
*/
public function disconnect(string $provider)
{
$this->oauth->disconnect($provider);
return $this->success();
}
/**
* Login with specified social provider.
*/
public function login(string $provider)
{
if (!settings("social.$provider.enable")) {
abort(403);
}
return $this->oauth->loginWith($provider);
}
public function loginCallback(string $provider)
{
if ($handler = Session::get(Oauth::OAUTH_CALLBACK_HANDLER_KEY)) {
return app($handler)->execute($provider);
}
$externalProfile = null;
try {
$externalProfile = $this->oauth->socializeWith(
$provider,
request('tokenFromApi'),
request('secretFromApi'),
);
} catch (Exception $e) {
Log::error($e);
}
if (!$externalProfile) {
return $this->oauth->getErrorResponse(
__('Could not retrieve social sign in account.'),
);
}
// TODO: use new "OAUTH_CALLBACK_HANDLER_KEY" functionality to handle this, remove "tokenFromApi" stuff from this handler
if (Session::get(Oauth::RETRIEVE_PROFILE_ONLY_KEY)) {
Session::forget(Oauth::RETRIEVE_PROFILE_ONLY_KEY);
return $this->oauth->returnProfileData($externalProfile);
}
$existingProfile = $this->oauth->getExistingProfile($externalProfile);
// if user is already logged in, attach returned social account to logged-in user
if (Auth::check()) {
return $this->oauth->attachProfileToExistingUser(
Auth::user(),
$externalProfile,
$provider,
);
}
// if we have already created a user for this social account, log user in
if ($existingProfile?->user) {
$this->oauth->updateSocialProfileData(
$existingProfile,
$provider,
$externalProfile,
);
return $this->oauth->logUserIn($existingProfile->user, $provider);
}
// if user is trying to log in with envato and does not have any valid purchases, bail
if (
$provider === 'envato' &&
empty($externalProfile->user['purchases'])
) {
return $this->oauth->getErrorResponse(
'You do not have any supported purchases.',
);
}
// need to request password from user in order to connect accounts
$user = User::where('email', $externalProfile->email)->first();
if ($user?->password) {
$this->oauth->persistSocialProfileData([
'service' => $provider,
'profile' => $externalProfile,
]);
return $this->oauth->getPopupResponse('REQUEST_PASSWORD');
}
// if we have email and didn't create an account for this profile yet, do it now
return $this->oauth->createUserFromOAuthData([
'profile' => $externalProfile,
'service' => $provider,
]);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Common\Auth\Controllers;
use Common\Core\BaseController;
use Illuminate\Support\Facades\Auth;
class TwoFactorQrCodeController extends BaseController
{
public function show()
{
return $this->success([
'svg' => Auth::user()->twoFactorQrCodeSvg(),
'secret' => decrypt(Auth::user()->two_factor_secret),
]);
}
}

View File

@@ -0,0 +1,62 @@
<?php namespace Common\Auth\Controllers;
use App\Models\User;
use Common\Auth\Events\UserAvatarChanged;
use Common\Core\BaseController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class UserAvatarController extends BaseController
{
public function __construct(
protected Request $request,
protected User $user,
) {
}
public function store(User $user)
{
$this->authorize('update', $user);
$this->validate($this->request, [
'file' => 'required_without:url|image|max:1500',
'url' => 'required_without:file|string|max:250',
]);
// delete old user avatar
if ($user->getRawOriginal('avatar')) {
Storage::disk('public')->delete($user->getRawOriginal('avatar'));
}
// store new avatar on public disk
$path =
$this->request->get('url') ??
$this->request
->file('file')
->storePublicly('avatars', ['disk' => 'public']);
// attach avatar to user model
$user->avatar = $path;
$user->save();
event(new UserAvatarChanged($user));
return $this->success(['user' => $user]);
}
public function destroy(User $user)
{
$this->authorize('update', $user);
if ($user->getRawOriginal('avatar')) {
Storage::disk('public')->delete($user->getRawOriginal('avatar'));
}
$user->avatar = null;
$user->save();
event(new UserAvatarChanged($user));
return $this->success();
}
}

View File

@@ -0,0 +1,105 @@
<?php namespace Common\Auth\Controllers;
use App\Models\User;
use Auth;
use Common\Auth\Actions\CreateUser;
use Common\Auth\Actions\DeleteUsers;
use Common\Auth\Actions\PaginateUsers;
use Common\Auth\Actions\UpdateUser;
use Common\Auth\Requests\CrupdateUserRequest;
use Common\Core\BaseController;
class UserController extends BaseController
{
public function __construct()
{
$this->middleware('auth', ['except' => ['show']]);
}
public function index()
{
$this->authorize('index', User::class);
$pagination = (new PaginateUsers())->execute(request()->all());
return $this->success(['pagination' => $pagination]);
}
public function show(User $user)
{
$relations = array_filter(explode(',', request('with', '')));
$relations = array_merge(['roles', 'social_profiles'], $relations);
if (settings('envato.enable')) {
$relations[] = 'purchase_codes';
}
if (Auth::id() === $user->id) {
$relations[] = 'tokens';
$user->makeVisible([
'two_factor_confirmed_at',
'two_factor_recovery_codes',
]);
if ($user->two_factor_confirmed_at) {
$user->two_factor_recovery_codes = $user->recoveryCodes();
$user->syncOriginal();
}
}
$user->load($relations);
$this->authorize('show', $user);
return $this->success(['user' => $user]);
}
public function store(CrupdateUserRequest $request)
{
$this->authorize('store', User::class);
$user = (new CreateUser())->execute($request->validated());
return $this->success(['user' => $user], 201);
}
public function update(User $user, CrupdateUserRequest $request)
{
$this->authorize('update', $user);
$user = (new UpdateUser())->execute($user, $request->validated());
return $this->success(['user' => $user]);
}
public function destroy(string $ids)
{
$userIds = explode(',', $ids);
$shouldDeleteCurrentUser = request('deleteCurrentUser');
$this->authorize('destroy', [User::class, $userIds]);
$users = User::whereIn('id', $userIds)->get();
// guard against current user or admin user deletion
foreach ($users as $user) {
if (!$shouldDeleteCurrentUser && $user->id === Auth::id()) {
return $this->error(
__('Could not delete currently logged in user: :email', [
'email' => $user->email,
]),
);
}
if ($user->hasPermission('admin')) {
return $this->error(
__('Could not delete admin user: :email', [
'email' => $user->email,
]),
);
}
}
(new DeleteUsers())->execute($users->pluck('id')->toArray());
return $this->success();
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Common\Auth\Controllers;
use App\Models\User;
use Common\Core\BaseController;
class UserFollowedUsersController extends BaseController
{
public function index(User $user)
{
$this->authorize('show', $user);
$pagination = $user
->followedUsers()
->withCount(['followers'])
->paginate(request('perPage', 20));
return $this->success(['pagination' => $pagination]);
}
public function ids(User $user)
{
$this->authorize('show', $user);
$ids = $user->followedUsers()->pluck('id');
return $this->success(['ids' => $ids]);
}
}

View File

@@ -0,0 +1,47 @@
<?php namespace Common\Auth\Controllers;
use App\Models\User;
use Auth;
use Common\Core\BaseController;
class UserFollowersController extends BaseController
{
public function __construct()
{
$this->middleware('auth')->except(['index']);
}
public function index(User $user)
{
$this->authorize('show', $user);
$pagination = $user
->followers()
->withCount(['followers'])
->simplePaginate(request('perPage') ?? 20);
return $this->success(['pagination' => $pagination]);
}
public function follow(User $userToFollow)
{
if ($userToFollow->id !== Auth::user()->id) {
Auth::user()
->followedUsers()
->sync([$userToFollow->id], false);
}
return $this->success();
}
public function unfollow(User $userToFollow)
{
if ($userToFollow->id != Auth::user()->id) {
Auth::user()
->followedUsers()
->detach($userToFollow->id);
}
return $this->success();
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Common\Auth\Controllers;
use Common\Auth\ActiveSession;
use Common\Core\BaseController;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Support\Facades\Auth;
use Jenssegers\Agent\Agent;
class UserSessionsController extends BaseController
{
public function __construct()
{
$this->middleware('auth');
}
public function index()
{
$sessions = Auth::user()
->activeSessions()
->orderBy('updated_at', 'desc')
->limit(30)
->get()
->map(function (ActiveSession $session) {
$agent = new Agent(null, $session->user_agent);
$location = geoip($session->ip_address);
$isCurrentDevice = $session->session_id
? $session->session_id ===
request()
->session()
->getId()
: $session->token ===
Auth::user()->currentAccessToken()->token;
return [
'id' => $session->id,
'platform' => $agent->platform(),
'device_type' => $agent->deviceType(),
'browser' => $agent->browser(),
'country' => $location->country,
'city' => $location->city,
'ip_address' => config('common.site.demo')
? 'Hidden on demo site'
: $session->ip_address,
'is_current_device' => $isCurrentDevice,
'last_active' => $session->updated_at,
];
})
->values();
return $this->success(['sessions' => $sessions]);
}
public function LogoutOtherSessions(StatefulGuard $guard)
{
$data = $this->validate(request(), [
'password' => 'required',
]);
$guard->logoutOtherDevices($data['password']);
ActiveSession::where('user_id', $guard->id())
->whereNotNull('session_id')
->where(
'session_id',
'!=',
request()
->session()
->getId(),
)
->delete();
return $this->success();
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Common\Auth\Events;
use App\Models\User;
class SocialConnected
{
public function __construct(public User $user, public string $socialName)
{
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Common\Auth\Events;
use App\Models\User;
class SocialLogin
{
public function __construct(public User $user, public string $socialName)
{
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Common\Auth\Events;
use App\Models\User;
class UserAvatarChanged
{
/**
* @var User
*/
public $user;
/**
* @param User $user
*/
public function __construct(User $user)
{
$this->user = $user;
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Common\Auth\Events;
use App\Models\User;
class UserCreated
{
public function __construct(public User $user, public array $data)
{
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Common\Auth\Events;
use Illuminate\Database\Eloquent\Collection;
class UsersDeleted
{
public Collection $users;
public function __construct(Collection $users)
{
$this->users = $users;
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Common\Auth\Fortify;
use App\Models\User;
use Closure;
use Common\Auth\Actions\CreateUser;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class FortifyRegisterUser implements CreatesNewUsers
{
use PasswordValidationRules;
public function create(array $input): User
{
if (settings('registration.disable')) {
abort(404);
}
$appRules = config('common.registration-rules') ?? [];
$commonRules = [
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
function (string $attribute, mixed $value, Closure $fail) {
if (!self::emailIsValid($value)) {
$fail(__('This domain is blacklisted.'));
}
},
],
'password' => $this->passwordRules(),
'token_name' => 'string|min:3|max:50',
];
foreach ($appRules as $key => $rules) {
$commonRules[$key] = array_map(function ($rule) {
if (str_contains($rule, '\\')) {
$namespace = "\\$rule";
return new $namespace();
}
return $rule;
}, $rules);
}
$data = Validator::make($input, $commonRules)->validate();
return (new CreateUser())->execute($data);
}
public static function emailIsValid(string $email): bool
{
$blacklistedDomains = explode(
',',
settings('auth.domain_blacklist', ''),
);
if ($blacklistedDomains) {
$domain = explode('@', $email)[1] ?? null;
if ($domain && in_array($domain, $blacklistedDomains)) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Common\Auth\Fortify;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Laravel\Fortify\Contracts\LogoutResponse as LogoutResponseContract;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->instance(LoginResponseContract::class, new LoginResponse());
$this->app->instance(
TwoFactorLoginResponseContract::class,
new TwoFactorLoginResponse(),
);
$this->app->instance(
LogoutResponseContract::class,
new LogoutResponse(),
);
$this->app->instance(
RegisterResponseContract::class,
new RegisterResponse(),
);
}
public function boot()
{
Fortify::createUsersUsing(FortifyRegisterUser::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
RateLimiter::for('login', function (Request $request) {
$email = (string) $request->email;
return Limit::perMinute(5)->by($email . $request->ip());
});
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by(
$request->session()->get('login.id'),
);
});
Fortify::authenticateUsing(function (Request $request) {
return (new ValidateLoginCredentials())->execute($request);
});
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Common\Auth\Fortify;
use Common\Core\Bootstrap\BootstrapData;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
class LoginResponse implements LoginResponseContract
{
public function toResponse($request): JsonResponse
{
if ($request->get('password') && settings('single_device_login')) {
Auth::logoutOtherDevices($request->get('password'));
}
$data = app(BootstrapData::class)
->init()
->getEncoded();
return response()->json([
'bootstrapData' => $data,
'two_factor' => false,
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Common\Auth\Fortify;
use Common\Core\Bootstrap\BootstrapData;
use Illuminate\Http\JsonResponse;
use Laravel\Fortify\Contracts\LogoutResponse as LogoutResponseContract;
class LogoutResponse implements LogoutResponseContract
{
public function toResponse($request): JsonResponse
{
$data = app(BootstrapData::class)
->init()
->getEncoded();
session()->forget('impersonator_id');
return response()->json([
'bootstrapData' => $data,
'status' => 'success',
]);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Common\Auth\Fortify;
use Laravel\Fortify\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*/
protected function passwordRules(): array
{
$password = new Password();
$password->length(5);
return ['required', 'string', $password, 'confirmed'];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Common\Auth\Fortify;
use Common\Core\Bootstrap\BootstrapData;
use Common\Core\Bootstrap\MobileBootstrapData;
use Illuminate\Http\JsonResponse;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
class RegisterResponse implements RegisterResponseContract
{
public function toResponse($request): JsonResponse
{
$response = [
'status' => $request->user()->hasVerifiedEmail()
? 'success'
: 'needs_email_verification',
];
// for mobile
if ($request->has('token_name')) {
$bootstrapData = app(MobileBootstrapData::class)->init();
$bootstrapData->refreshToken($request->get('token_name'));
$response['bootstrapData'] = $bootstrapData->get();
// for web
} else {
$bootstrapData = app(BootstrapData::class)->init();
$response['bootstrapData'] = $bootstrapData->getEncoded();
}
return response()->json($response);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Common\Auth\Fortify;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param mixed $user
* @param array $input
* @return void
*/
public function reset($user, array $input)
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user
->forceFill([
'password' => $input['password'],
])
->save();
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Common\Auth\Fortify;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract;
class TwoFactorLoginResponse extends LoginResponse implements
TwoFactorLoginResponseContract
{
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Common\Auth\Fortify;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
use PasswordValidationRules;
public function update($user, array $input)
{
Validator::make(
$input,
[
'current_password' => [
'required',
'string',
'current_password:web',
],
'password' => $this->passwordRules(),
],
[
'current_password.current_password' => __(
'The provided password does not match your current password.',
),
],
)->validateWithBag('updatePassword');
$user
->forceFill([
'password' => $input['password'],
])
->save();
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Common\Auth\Fortify;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Fortify;
use Laravel\Fortify\LoginRateLimiter;
class ValidateLoginCredentials
{
public function execute(Request $request): ?User
{
$user = User::where('email', $request->email)->first();
if (!FortifyRegisterUser::emailIsValid($request->email)) {
$this->throwFailedAuthenticationException(
$request,
__('This domain is blacklisted.'),
);
}
if ($user?->isBanned()) {
$comment = $user->bans()->first()->comment;
$this->throwFailedAuthenticationException(
$request,
$comment
? __('Banned: :reason', ['reason' => $comment])
: __('This user is banned.'),
);
}
if ($user && Hash::check($request->password, $user->password)) {
return $user;
}
return null;
}
public function throwFailedAuthenticationException(
Request $request,
string $message,
) {
app(LoginRateLimiter::class)->increment($request);
throw ValidationException::withMessages([
Fortify::username() => [$message],
]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Common\Auth\Jobs;
use Common\Auth\Roles\Role;
use Common\Csv\BaseCsvExportJob;
use Illuminate\Bus\Queueable;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
class ExportRolesCsv extends BaseCsvExportJob
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* @var int
*/
protected $requesterId;
public function __construct(int $requesterId)
{
$this->requesterId = $requesterId;
}
public function cacheName(): string
{
return 'roles';
}
protected function generateLines()
{
$selectCols = [
'id',
'name',
'description',
'type',
'internal',
'created_at',
'updated_at',
];
Role::select($selectCols)->chunkById(100, function (Collection $chunk) {
$chunk->each(function (Role $role) {
$data = $role->toArray();
$this->writeLineToCsv($data);
});
});
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Common\Auth\Jobs;
use App\Models\User;
use Common\Csv\BaseCsvExportJob;
use Illuminate\Bus\Queueable;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
class ExportUsersCsv extends BaseCsvExportJob
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* @var int
*/
protected $requesterId;
public function __construct(int $requesterId)
{
$this->requesterId = $requesterId;
}
public function cacheName(): string
{
return 'users';
}
protected function generateLines()
{
$selectCols = [
'id',
'email',
'username',
'first_name',
'last_name',
'avatar',
'created_at',
'language',
'country',
'timezone',
];
User::select($selectCols)->chunkById(100, function (Collection $chunk) {
$chunk->each(function (User $user) {
$data = $user->toArray();
unset($data['display_name'], $data['has_password']);
$this->writeLineToCsv($data);
});
});
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Common\Auth\Jobs;
use Common\Auth\ActiveSession;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class LogActiveSessionJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(protected array $data)
{
}
public function handle(): void
{
$sessionId = $this->data['session_id'] ?? null;
$token = $this->data['token'] ?? null;
$existingSession = app(ActiveSession::class)
->when(
$sessionId,
fn($query) => $query->where('session_id', $sessionId),
)
->when($token, fn($query) => $query->where('token', $token))
->where('user_id', $this->data['user_id'])
->first();
if ($existingSession) {
$existingSession->touch('updated_at');
} else {
$this->createNewSession();
}
}
protected function createNewSession()
{
ActiveSession::create([
'session_id' => $this->data['session_id'] ?? null,
'token' => $this->data['token'] ?? null,
'user_id' => $this->data['user_id'],
'ip_address' => $this->data['ip_address'] ?? null,
'user_agent' => $this->data['user_agent'] ?? null,
]);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Common\Auth\Middleware;
use Closure;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Http\Request;
class ForbidBannedUser
{
public function __construct(protected StatefulGuard $guard)
{
}
public function handle(Request $request, Closure $next)
{
if ($request->user() && $request->user()->isBanned()) {
$this->guard->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
abort(403);
}
return $next($request);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Common\Auth\Middleware;
use Illuminate\Auth\Middleware\Authenticate;
class OptionalAuthenticate extends Authenticate
{
// prevent authentication exception if user is not logged in at all. This will be handled in policies instead
protected function unauthenticated($request, array $guards)
{
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Common\Auth\Middleware;
use Closure;
use Illuminate\Http\Request;
class VerifyApiAccessMiddleware
{
public function handle(Request $request, Closure $next)
{
$model = $request->user() ?: app('guestRole');
if (!requestIsFromFrontend() && !$model->hasPermission('api.access')) {
abort(401);
}
return $next($request);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Common\Auth\Notifications;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\HtmlString;
class VerifyEmailWithOtp extends Notification
{
public function __construct(public string $otp)
{
}
public function via($notifiable)
{
return ['mail'];
}
public function toMail($notifiable): MailMessage
{
$accountSettingsUrl = url('account-settings');
$pStyle =
'line-height: 30px; text-align: left; font-weight: normal; font-style: normal; letter-spacing: 0.48px; color: #718096';
$title = __('Your :site security code is:', [
'site' => config('app.name'),
]);
$accountSettingsTxt = __('Account settings');
return (new MailMessage())
->subject(
__('Your :site security code is :code', [
'site' => config('app.name'),
'code' => $this->otp,
]),
)
->greeting(new HtmlString("<h1 style=\"$pStyle\">$title</h1>"))
->line(
new HtmlString(
'<b style="font-size: 48px; font-style: normal; font-weight: bold; padding: 20px 0; line-height: 54px; color: #3d4852">' .
$this->otp .
'</b>',
),
)
->line(
__(
'If you did not request this code, please go to your :link and change your password right away.',
[
'link' => "<a href=\"$accountSettingsUrl\">$accountSettingsTxt</a>",
],
),
)
->line(
__('This code will expire in :minutes minutes.', [
'minutes' => 30,
]),
);
}
}

344
common/Auth/Oauth.php Executable file
View File

@@ -0,0 +1,344 @@
<?php namespace Common\Auth;
use App\Models\User;
use Carbon\Carbon;
use Common\Auth\Actions\CreateUser;
use Common\Auth\Events\SocialConnected;
use Common\Auth\Events\SocialLogin;
use Common\Core\Bootstrap\BootstrapData;
use Common\Core\Bootstrap\MobileBootstrapData;
use Illuminate\Contracts\View\View as ViewContract;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\View;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\One\User as OneUser;
use Laravel\Socialite\Two\User as TwoUser;
class Oauth
{
const OAUTH_CALLBACK_HANDLER_KEY = 'oauthCallbackHandler';
const RETRIEVE_PROFILE_ONLY_KEY = 'retrieveProfileOnly';
private array $validProviders = ['google', 'facebook', 'twitter', 'envato'];
public function loginWith(string $provider)
{
if (Auth::user()) {
return View::make('common::oauth/popup')->with(
'status',
'ALREADY_LOGGED_IN',
);
}
return $this->redirect($provider);
}
public function redirect(string $providerName)
{
$this->validateProvider($providerName);
return Socialite::driver($providerName)->redirect();
}
/**
* Retrieve user details from specified social account without logging user in or connecting accounts.
*/
public function retrieveProfileOnly(string $providerName)
{
$this->validateProvider($providerName);
Session::put([Oauth::RETRIEVE_PROFILE_ONLY_KEY => true]);
$driver = Socialite::driver($providerName);
// get user profile url from facebook
if ($providerName === 'facebook') {
$driver->scopes(['user_link']);
}
return $driver->redirect();
}
/**
* Disconnect specified social account from currently logged-in user.
*/
public function disconnect(string $provider): void
{
$this->validateProvider($provider);
Auth::user()
->social_profiles()
->where('service_name', $provider)
->delete();
}
/**
* Get user profile from specified social provider or throw 404 if it's invalid.
*/
public function socializeWith(
string $provider,
?string $token,
?string $secret,
) {
$this->validateProvider($provider);
if ($token && $secret) {
$user = Socialite::driver($provider)->userFromTokenAndSecret(
$token,
$secret,
);
} elseif ($token) {
$user = Socialite::driver($provider)->userFromToken($token);
} else {
$user = Socialite::with($provider)->user();
}
return $user;
}
/**
* Return existing social profile from database for specified external social profile.
*/
public function getExistingProfile(mixed $profile): ?SocialProfile
{
if (!$profile) {
return null;
}
return SocialProfile::where(
'user_service_id',
$this->getUsersIdentifierOnService($profile),
)
->with('user')
->first();
}
/**
* Create a new user from given social profile and log him in.
*/
public function createUserFromOAuthData(array $data)
{
$profile = $data['profile'];
$service = $data['service'];
$user = User::where('email', $profile->email)->first();
//create a new user if one does not exist with specified email
if (!$user) {
$img = str_replace('http://', 'https://', $profile->avatar);
$user = (new CreateUser())->execute([
'email' => $profile->email,
'avatar' => $img,
'email_verified_at' => now(),
]);
}
//save this social profile data, so we can log in the user easily next time
$user
->social_profiles()
->create(
$this->transformSocialProfileData(
$service,
$profile,
$user->id,
),
);
return $this->logUserIn($user, $service);
}
public function updateSocialProfileData(
SocialProfile $profile,
string $service,
$data,
User|null $user = null,
): void {
$data = $this->transformSocialProfileData(
$service,
$data,
$user->id ?? $profile->user_id,
);
$profile->fill($data)->save();
}
public function attachProfileToExistingUser(
User $user,
mixed $profile,
string $service,
) {
$payload = $this->transformSocialProfileData(
$service,
$profile,
$user->id,
);
//if this social account is already attached to some user
//we will re-attach it to specified user
if ($existing = $this->getExistingProfile($profile)) {
$this->updateSocialProfileData(
$existing,
$service,
$profile,
$user,
);
//if social account is not attached to any user, we will
//create a model for it and attach it to specified user
} else {
$user->social_profiles()->create($payload);
}
$response = [
'bootstrapData' => app(BootstrapData::class)
->init()
->getEncoded(),
];
event(new SocialConnected($user, $service));
return request()->expectsJson()
? $response
: $this->getPopupResponse('SUCCESS', $response);
}
private function transformSocialProfileData(
string $service,
TwoUser|OneUser $data,
int $userId,
): array {
return [
'service_name' => $service,
'user_service_id' => $this->getUsersIdentifierOnService($data),
'user_id' => $userId,
'username' => $data->name,
'access_token' => $data->token ?? null,
'refresh_token' => $data->refreshToken ?? null,
'access_expires_at' =>
isset($data->expiresIn) && $data->expiresIn
? Carbon::now()->addSeconds($data->expiresIn)
: null,
];
}
public function returnProfileData($externalProfile)
{
$normalizedProfile = [
'id' => $externalProfile->id,
'name' => $externalProfile->name,
'email' => $externalProfile->email,
'avatar' => $externalProfile->avatar,
'profileUrl' => $externalProfile->profileUrl,
];
if (request()->expectsJson()) {
return ['profile' => $normalizedProfile];
} else {
return $this->getPopupResponse('SUCCESS_PROFILE_RETRIEVE', [
'profile' => $normalizedProfile,
]);
}
}
/**
* Log given user into the app and return
* a view to close popup in front end.
*/
public function logUserIn(User $user, string $serviceName)
{
Auth::loginUsingId($user->id, true);
if (request('tokenForDevice')) {
$response = app(MobileBootstrapData::class)
->init()
->refreshToken(request('tokenForDevice'))
->get();
} else {
$response = [
'bootstrapData' => app(BootstrapData::class)
->init()
->getEncoded(),
];
}
event(new SocialLogin($user, $serviceName));
if (request()->expectsJson()) {
return $response;
} else {
return $this->getPopupResponse('SUCCESS', $response);
}
}
public function getErrorResponse(string $message)
{
if (request()->wantsJson()) {
return response()->json(['message' => $message], 500);
} else {
return $this->getPopupResponse('ERROR', [
'errorMessage' => $message,
]);
}
}
/**
* Get oauth data persisted in current session.
*
* @param string $key
* @return mixed
*/
public function getPersistedData($key = null)
{
//test session when not logged, what if multiple users log in at same time etc
$data = Session::get('social_profile');
if (!$key) {
return $data;
}
if ($key && isset($data[$key])) {
return $data[$key];
}
}
/**
* Store specified social profile information in the session
* for use in subsequent social login process steps.
*/
public function persistSocialProfileData(array $data): void
{
foreach ($data as $key => $value) {
Session::put("social_profile.$key", $value);
}
}
/**
* Check if provider user want to login with is valid, if not throw 404
*/
private function validateProvider(string $provider): void
{
if (!in_array($provider, $this->validProviders)) {
abort(404);
}
}
/**
* Get users unique identifier on social service from given profile.
*/
private function getUsersIdentifierOnService(mixed $profile): int|string
{
return $profile->id ?? $profile->email;
}
public function getPopupResponse(string $status, $data = null): ViewContract
{
$view = View::make('common::oauth/popup')->with('status', $status);
if ($data) {
$view->with('data', json_encode($data));
}
return $view;
}
}

36
common/Auth/OtpCode.php Executable file
View File

@@ -0,0 +1,36 @@
<?php
namespace Common\Auth;
use Illuminate\Database\Eloquent\Model;
class OtpCode extends Model
{
const TYPE_EMAIL_VERIFICATION = 'email_verification';
protected $guarded = ['id'];
protected $casts = [
'expires_at' => 'datetime',
];
public $timestamps = false;
public function isExpired(): bool
{
return now()->gte($this->expires_at);
}
public static function createForEmailVerification(int $userId)
{
self::where('user_id', $userId)
->where('type', static::TYPE_EMAIL_VERIFICATION)
->delete();
return static::create([
'user_id' => $userId,
'type' => static::TYPE_EMAIL_VERIFICATION,
'code' => random_int(100000, 999999),
'expires_at' => now()->addMinutes(30),
]);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Common\Auth\Permissions;
use Arr;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
class Permission extends Model
{
protected $guarded = ['id'];
protected $casts = [
'id' => 'integer',
'advanced' => 'integer',
];
protected $hidden = ['pivot', 'permissionable_type'];
const MODEL_TYPE = 'permission';
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
/**
* @param string|array $value
* @return Collection
*/
public function getRestrictionsAttribute($value)
{
// if loading permissions via parent (user, role, plan) return restrictions
// stored on pivot table, otherwise return restrictions stored on permission itself
$value = $this->pivot ? $this->pivot->restrictions : $value;
if ( ! $value) $value = [];
return collect(is_string($value) ? json_decode($value, true) : $value)->values();
}
public function setRestrictionsAttribute($value)
{
if ($value && is_array($value)) {
$this->attributes['restrictions'] = json_encode(array_values($value));
}
}
public function getRestrictionValue(string $name): int | bool | null
{
$restriction = $this->restrictions->first(function($restriction) use($name) {
return $restriction['name'] === $name;
});
return Arr::get($restriction, 'value') ?? null;
}
/**
* Merge restrictions from specified permission into this permission.
*/
public function mergeRestrictions(Permission $permission = null): self
{
if ($permission) {
$permission->restrictions->each(function($restriction) {
$exists = $this->restrictions->first(function($r) use($restriction) {
return $r['name'] === $restriction['name'];
});
if ( ! $exists) {
$this->restrictions->push($restriction);
}
});
}
return $this;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Common\Auth\Permissions\Policies;
use Common\Auth\Permissions\Permission;
use Common\Auth\BaseUser;
use Illuminate\Http\Request;
use Illuminate\Auth\Access\HandlesAuthorization;
class PermissionPolicy
{
use HandlesAuthorization;
/**
* @var Request
*/
private $request;
public function __construct(Request $request)
{
$this->request = $request;
}
public function index(BaseUser $user)
{
return $user->hasPermission('permission.view');
}
public function show(BaseUser $user, Permission $permission)
{
return $user->hasPermission('permission.view');
}
public function store(BaseUser $user)
{
return $user->hasPermission('permission.create');
}
public function update(BaseUser $user)
{
return $user->hasPermission('permission.update');
}
public function destroy(BaseUser $user)
{
return $user->hasPermission('permission.delete');
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Common\Auth\Permissions\Traits;
use Common\Auth\Permissions\Permission;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
trait HasPermissionsRelation
{
public function permissions(): MorphToMany
{
return $this->morphToMany(Permission::class, 'permissionable')
->withPivot('restrictions')
->select('name', 'permissions.id', 'permissions.restrictions');
}
public function hasPermission(string $name): bool
{
return !is_null($this->getPermission($name)) ||
!is_null($this->getPermission('admin'));
}
public function hasExactPermission(string $name): bool
{
return !is_null($this->getPermission($name));
}
public function getPermission(string $name): Permission|null
{
if (method_exists($this, 'loadPermissions')) {
$this->loadPermissions();
}
foreach ($this->permissions as $permission) {
if ($permission->name === $name) {
return $permission;
}
}
return null;
}
public function getRestrictionValue(
string $permissionName,
string $restriction,
): int|bool|null {
$permission = $this->getPermission($permissionName);
return $permission?->getRestrictionValue($restriction);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Common\Auth\Permissions\Traits;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
trait SyncsPermissions
{
public function syncPermissions(
Model $model,
array|Collection $permissions
): void {
$permissionIds = collect($permissions)->mapWithKeys(function (
$permission
) {
$restrictions = Arr::get($permission, 'restrictions', []);
return [
$permission['id'] => [
'restrictions' => collect($restrictions)
->filter(function ($restriction) {
return isset($restriction['value']);
})
->map(function ($restriction) {
return [
'name' => $restriction['name'],
'value' => $restriction['value'],
];
}),
],
];
});
$model->permissions()->sync($permissionIds);
}
}

View File

@@ -0,0 +1,37 @@
<?php namespace Common\Auth\Requests;
use Common\Core\BaseFormRequest;
class CrupdateUserRequest extends BaseFormRequest
{
public function rules(): array
{
$except = $this->getMethod() === 'PUT' ? $this->route('user')->id : '';
$rules = [
'email' => "email|min:3|max:255|unique:users,email,$except",
'password' => 'min:3|max:255',
'avatar' => 'string|max:255|nullable',
'email_verified_at' => '', // can be date string or boolean
// alpha and space/dash
'first_name' =>
'string|min:2|max:255|nullable|regex:/^[\pL\s\-]+$/u',
'last_name' =>
'string|min:2|max:255|nullable|regex:/^[\pL\s\-]+$/u',
'permissions' => 'array',
'roles' => 'array',
'roles.*' => 'int',
'available_space' => 'nullable|min:0',
'country' => 'nullable|string|max:255',
'language' => 'nullable|string|max:255',
'timezone' => 'nullable|string|max:255',
];
if ($this->method() === 'POST') {
$rules['email'] = 'required|' . $rules['email'];
$rules['password'] = 'required|' . $rules['password'];
}
return $rules;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Common\Auth\Roles;
use Common\Auth\Permissions\Traits\SyncsPermissions;
use Illuminate\Support\Arr;
class CrupdateRole
{
use SyncsPermissions;
/**
* @var Role
*/
private $role;
/**
* @param Role $role
*/
public function __construct(Role $role)
{
$this->role = $role;
}
/**
* @param array $data
* @param Role $role
* @return Role
*/
public function execute($data, $role = null)
{
if (!$role) {
$role = $this->role->newInstance([]);
}
$attributes = [
'name' => $data['name'],
'description' => $data['description'] ?? null,
'default' => $data['default'] ?? false,
'guests' => $data['guests'] ?? false,
'type' => $data['type'] ?? 'sitewide',
];
$role->fill($attributes)->save();
// always sync permissions, detach all if "null" is given as permissions
$this->syncPermissions($role, Arr::get($data, 'permissions', []));
return $role;
}
}

70
common/Auth/Roles/Role.php Executable file
View File

@@ -0,0 +1,70 @@
<?php namespace Common\Auth\Roles;
use App\Models\User;
use Common\Auth\Permissions\Traits\HasPermissionsRelation;
use Common\Core\BaseModel;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Role extends BaseModel
{
use HasPermissionsRelation;
const MODEL_TYPE = 'role';
protected $guarded = ['id'];
protected $hidden = ['pivot', 'legacy_permissions'];
protected $casts = [
'id' => 'integer',
'default' => 'boolean',
'guests' => 'boolean',
'internal' => 'boolean',
];
/**
* Get default role for assigning to new users.
*/
public function getDefaultRole(): ?Role
{
return $this->where('default', 1)->first();
}
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'user_role')->withPivot(
'created_at',
);
}
public function toNormalizedArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'model_type' => self::MODEL_TYPE,
];
}
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'type' => $this->type,
'created_at' => $this->created_at->timestamp ?? '_null',
'updated_at' => $this->updated_at->timestamp ?? '_null',
];
}
public static function filterableFields(): array
{
return ['id', 'type', 'created_at', 'updated_at'];
}
public static function getModelTypeAttribute(): string
{
return Role::MODEL_TYPE;
}
}

View File

@@ -0,0 +1,146 @@
<?php namespace Common\Auth\Roles;
use App\Models\User;
use Common\Core\BaseController;
use Common\Database\Datasource\Datasource;
use Illuminate\Http\Request;
class RolesController extends BaseController
{
/**
* @var User
*/
private $user;
/**
* @var Role
*/
private $role;
/**
* @var Request
*/
private $request;
public function __construct(Request $request, Role $role, User $user)
{
$this->role = $role;
$this->user = $user;
$this->request = $request;
}
public function show(Role $role)
{
$this->authorize('show', Role::class);
$role->load(['permissions']);
return $this->success(['role' => $role]);
}
public function index()
{
$this->authorize('index', Role::class);
$pagination = (new Datasource(
$this->role,
request()->all(),
))->paginate();
return $this->success(['pagination' => $pagination]);
}
public function store()
{
$this->authorize('store', Role::class);
$this->validate($this->request, [
'name' => 'required|unique:roles|min:2|max:255',
'default' => 'nullable|boolean',
'guests' => 'nullable|boolean',
'permissions' => 'nullable|array',
]);
$role = app(CrupdateRole::class)->execute($this->request->all());
return $this->success(['role' => $role], 201);
}
public function update(int $id)
{
$this->authorize('update', Role::class);
$this->validate($this->request, [
'name' => "min:2|max:255|unique:roles,name,$id",
'default' => 'boolean',
'guests' => 'boolean',
'permissions' => 'array',
]);
$role = $this->role->findOrFail($id);
$role = app(CrupdateRole::class)->execute($this->request->all(), $role);
return $this->success(['role' => $role]);
}
public function destroy(int $id)
{
$role = $this->role->findOrFail($id);
$this->authorize('destroy', $role);
$role->users()->detach();
$role->delete();
return $this->success([], 204);
}
public function addUsers(int $roleId)
{
$this->authorize('update', Role::class);
$this->validate($this->request, [
'userIds' => 'required|array|min:1|max:25',
'userIds.*' => 'required|int',
]);
$role = $this->role->findOrFail($roleId);
$users = $this->user
->with('roles')
->whereIn('id', $this->request->get('userIds'))
->get(['email', 'id']);
if ($users->isEmpty()) {
return $this->error(
__('Could not attach specified users to role.'),
);
}
//filter out users that are already attached to this role
$users = $users->filter(function ($user) use ($roleId) {
return !$user->roles->contains('id', $roleId);
});
$role->users()->attach($users->pluck('id')->toArray());
return $this->success(['users' => $users]);
}
public function removeUsers(int $roleId)
{
$this->authorize('update', Role::class);
$this->validate($this->request, [
'userIds' => 'required|array|min:1',
'userIds.*' => 'required|integer',
]);
$role = $this->role->findOrFail($roleId);
$role->users()->detach($this->request->get('userIds'));
return $this->success();
}
}

View File

@@ -0,0 +1,36 @@
<?php namespace Common\Auth\Roles;
use App\Models\User;
use Common\Core\BaseController;
class UserRolesController extends BaseController
{
public function attach(int $userId)
{
$user = User::findOrFail($userId);
$this->authorize('update', $user);
$data = $this->validate(request(), [
'roles' => 'array',
'roles.*' => 'integer|exists:roles,id',
]);
$user->roles()->attach($data['roles']);
return $this->success();
}
public function detach(int $userId)
{
$user = User::findOrFail($userId);
$this->authorize('update', $user);
$data = $this->validate(request(), [
'roles' => 'array',
]);
return $user->roles()->detach($data['roles']);
}
}

25
common/Auth/SocialProfile.php Executable file
View File

@@ -0,0 +1,25 @@
<?php namespace Common\Auth;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class SocialProfile extends Model
{
protected $guarded = ['id'];
protected $casts = [
'access_expires_at' => 'datetime',
];
const MODEL_TYPE = 'social_profile';
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Common\Auth\Traits;
use Common\Auth\BaseUser;
use Storage;
use Str;
trait HasAvatarAttribute
{
public function getAvatarAttribute(?string $value)
{
// absolute link
if ($value && Str::contains($value, '//')) {
// change google/twitter avatar imported via social login size
$value = str_replace(
'.jpg?sz=50',
".jpg?sz=$this->gravatarSize",
$value,
);
if ($this->gravatarSize > 50) {
// twitter
$value = str_replace('_normal.jpg', '.jpg', $value);
}
return $value;
}
// relative link (for new and legacy urls)
if ($value) {
return Storage::disk('public')->url(
str_replace('storage/', '', $value),
);
}
// gravatar
$hash = md5(trim(strtolower($this->email)));
return "https://www.gravatar.com/avatar/$hash?s={$this->gravatarSize}&d=retro";
}
/**
* @param number $size
* @return BaseUser
*/
public function setGravatarSize($size)
{
$this->gravatarSize = $size;
return $this;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Common\Auth\Traits;
trait HasDisplayNameAttribute
{
/**
* Compile user display name from available attributes.
*
* @return string
*/
public function getDisplayNameAttribute()
{
if ($this->username) {
return $this->username;
} else if ($this->first_name && $this->last_name) {
return $this->first_name.' '.$this->last_name;
} else if ($this->first_name) {
return $this->first_name;
} else if ($this->last_name) {
return $this->last_name;
} else {
return explode('@', $this->email)[0];
}
}
}

View File

@@ -0,0 +1,30 @@
<?php namespace Common\Auth\Validators;
use App;
use App\Models\User;
use Common\Settings\Settings;
class EmailVerifiedValidator {
/**
* Check if user with specified email has verified his email address.
*
* @param string $attribute
* @param string $value
* @param array $parameters
* @return bool
*/
public function validate($attribute, $value, $parameters) {
$settings = App::make(Settings::class);
//don't need to validate email, bail
if ( ! $settings->get('require_email_confirmation')) return true;
//if email address is not taken yet, bail
if ( ! $user = User::where('email', $value)->first()) return true;
//check if specified email is verified
/** @var User $user */
return (bool) $user->hasVerifiedEmail();
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Common\Auth\Validators;
use Closure;
use Illuminate\Contracts\Validation\InvokableRule;
use Illuminate\Support\Facades\Hash;
class HashIsValid implements InvokableRule
{
public function __construct(protected string $hashedValue)
{
}
public function __invoke($attribute, mixed $value, $fail)
{
if (!Hash::check($value, $this->hashedValue)) {
return $fail('The :attribute is not valid')->translate();
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Common\Auth\Validators;
use Illuminate\Contracts\Validation\InvokableRule;
use Illuminate\Support\Facades\Hash;
class PasswordIsValid implements InvokableRule
{
public bool $implicit = true;
public function __construct(protected string $password)
{
}
public function __invoke($attribute, $value, $fail)
{
if (!Hash::check($value, $this->password)) {
$fail('Password does not match.')->translate();
}
}
}