Page MenuHomePhorge

No OneTemporary

diff --git a/src/app/AuthAttempt.php b/src/app/AuthAttempt.php
index 954198f2..23de2bbe 100644
--- a/src/app/AuthAttempt.php
+++ b/src/app/AuthAttempt.php
@@ -1,195 +1,195 @@
<?php
namespace App;
use App\Traits\UuidStrKeyTrait;
use Carbon\Carbon;
use Dyrynda\Database\Support\NullableFields;
use Illuminate\Database\Eloquent\Model;
/**
* The eloquent definition of an AuthAttempt.
*
* An AuthAttempt represents an authenticaton attempt from an application/client.
*/
class AuthAttempt extends Model
{
use NullableFields;
use UuidStrKeyTrait;
- // No specific reason
public const REASON_NONE = '';
- // Password mismatch
public const REASON_PASSWORD = 'password';
- // Geolocation whitelist mismatch
public const REASON_GEOLOCATION = 'geolocation';
+ public const REASON_NOTFOUND = 'notfound';
+ public const REASON_2FA = '2fa';
+ public const REASON_2FA_GENERIC = '2fa-generic';
private const STATUS_ACCEPTED = 'ACCEPTED';
private const STATUS_DENIED = 'DENIED';
/** @var array<int, string> The attributes that can be not set */
protected $nullable = ['reason'];
/** @var array<int, string> The attributes that are mass assignable */
protected $fillable = [
'ip',
'user_id',
'status',
'reason',
'expires_at',
'last_seen',
];
/** @var array<string, string> The attributes that should be cast */
protected $casts = [
'expires_at' => 'datetime',
'last_seen' => 'datetime'
];
/**
* Prepare a date for array / JSON serialization.
*
* Required to not omit timezone and match the format of update_at/created_at timestamps.
*
* @param \DateTimeInterface $date
* @return string
*/
protected function serializeDate(\DateTimeInterface $date): string
{
return Carbon::instance($date)->toIso8601ZuluString('microseconds');
}
/**
* Returns true if the authentication attempt is accepted.
*
* @return bool
*/
public function isAccepted(): bool
{
return $this->status == self::STATUS_ACCEPTED && Carbon::now() < $this->expires_at;
}
/**
* Returns true if the authentication attempt is denied.
*
* @return bool
*/
public function isDenied(): bool
{
return $this->status == self::STATUS_DENIED;
}
/**
* Accept the authentication attempt.
*/
public function accept($reason = AuthAttempt::REASON_NONE)
{
$this->expires_at = Carbon::now()->addHours(8);
$this->status = self::STATUS_ACCEPTED;
$this->reason = $reason;
$this->save();
}
/**
* Deny the authentication attempt.
*/
public function deny($reason = AuthAttempt::REASON_NONE)
{
$this->status = self::STATUS_DENIED;
$this->reason = $reason;
$this->save();
}
/**
* Notify the user of this authentication attempt.
*
* @return bool false if there was no means to notify
*/
public function notify(): bool
{
return CompanionApp::notifyUser($this->user_id, ['token' => $this->id]);
}
/**
* Notify the user and wait for a confirmation.
*/
private function notifyAndWait()
{
if (!$this->notify()) {
//FIXME if the webclient can confirm too we don't need to abort here.
\Log::warning("There is no 2fa device to notify.");
return false;
}
\Log::debug("Authentication attempt: {$this->id}");
$confirmationTimeout = 120;
$timeout = Carbon::now()->addSeconds($confirmationTimeout);
do {
if ($this->isDenied()) {
\Log::debug("The authentication attempt was denied {$this->id}");
return false;
}
if ($this->isAccepted()) {
\Log::debug("The authentication attempt was accepted {$this->id}");
return true;
}
if ($timeout < Carbon::now()) {
\Log::debug("The authentication attempt timed-out: {$this->id}");
return false;
}
sleep(2);
$this->refresh();
} while (true);
}
/**
* Record a new authentication attempt or update an existing one.
*
* @param \App\User $user The user attempting to authenticate.
* @param string $clientIP The ip the authentication attempt is coming from.
*
* @return \App\AuthAttempt
*/
public static function recordAuthAttempt(User $user, $clientIP)
{
$authAttempt = AuthAttempt::where('ip', $clientIP)->where('user_id', $user->id)->first();
if (!$authAttempt) {
$authAttempt = new AuthAttempt();
$authAttempt->ip = $clientIP;
$authAttempt->user_id = $user->id;
}
$authAttempt->last_seen = Carbon::now();
$authAttempt->save();
return $authAttempt;
}
/**
* Trigger a notification if necessary and wait for confirmation.
*
* @return bool Returns true if the attempt is accepted on confirmation
*/
public function waitFor2FA(): bool
{
if ($this->isAccepted()) {
return true;
}
if ($this->isDenied()) {
return false;
}
if (!$this->notifyAndWait()) {
return false;
}
return $this->isAccepted();
}
}
diff --git a/src/app/Http/Controllers/API/V4/NGINXController.php b/src/app/Http/Controllers/API/V4/NGINXController.php
index 3dbd9718..0b34bcfb 100644
--- a/src/app/Http/Controllers/API/V4/NGINXController.php
+++ b/src/app/Http/Controllers/API/V4/NGINXController.php
@@ -1,408 +1,379 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class NGINXController extends Controller
{
/**
* Authorize with the provided credentials.
*
- * @param string $login The login name
+ * @param string $login The login name
* @param string $password The password
*
* @return \App\User The user
*
* @throws \Exception If the authorization fails.
*/
private function authorizeRequestCredentialsOnly($login, $password)
{
if (empty($login)) {
throw new \Exception("Empty login");
}
if (empty($password)) {
throw new \Exception("Empty password");
}
$user = \App\User::where('email', $login)->first();
if (!$user) {
throw new \Exception("User not found");
}
if (!Hash::check($password, $user->password)) {
throw new \Exception("Password mismatch");
}
return $user;
}
/**
* Authorize with the provided credentials.
*
* @param string $login The login name
* @param string $password The password
* @param string $clientIP The client ip
*
* @return \App\User The user
*
* @throws \Exception If the authorization fails.
*/
private function authorizeRequest($login, $password, $clientIP)
{
if (empty($login)) {
throw new \Exception("Empty login");
}
if (empty($password)) {
throw new \Exception("Empty password");
}
if (empty($clientIP)) {
throw new \Exception("No client ip");
}
- $user = \App\User::where('email', $login)->first();
- if (!$user) {
- throw new \Exception("User not found");
+ $result = \App\User::findAndAuthenticate($login, $password, $clientIP);
+
+ if (empty($result['user'])) {
+ throw new \Exception($result['errorMessage'] ?? "Unknown error");
}
// TODO: validate the user's domain is A-OK (active, confirmed, not suspended, ldapready)
// TODO: validate the user is A-OK (active, not suspended, ldapready, imapready)
- // TODO: we could use User::findAndAuthenticate() with some modifications here
-
- if (!Hash::check($password, $user->password)) {
- $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
- // Avoid setting a password failure reason if we previously accepted the location.
- if (!$attempt->isAccepted()) {
- $attempt->reason = \App\AuthAttempt::REASON_PASSWORD;
- $attempt->save();
- $attempt->notify();
- }
- throw new \Exception("Password mismatch");
- }
-
- // validate country of origin against restrictions, otherwise bye bye
- if (!$user->validateLocation($clientIP)) {
- \Log::info("Failed authentication attempt due to country code mismatch for user: {$login}");
- $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
- $attempt->deny(\App\AuthAttempt::REASON_GEOLOCATION);
- $attempt->notify();
- throw new \Exception("Country code mismatch");
- }
-
// TODO: Apply some sort of limit for Auth-Login-Attempt -- docs say it is the number of
- // attempts over the same authAttempt.
+ // attempts over the same authAttempt.
- // Check 2fa
- if (\App\CompanionApp::where('user_id', $user->id)->exists()) {
- $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
- if (!$authAttempt->waitFor2FA()) {
- throw new \Exception("2fa failed");
- }
- }
-
- return $user;
+ return $result['user'];
}
/**
* Convert domain.tld\username into username@domain for activesync
*
* @param string $username The original username.
*
* @return string The username in canonical form
*/
private function normalizeUsername($username)
{
$usernameParts = explode("\\", $username);
if (count($usernameParts) == 2) {
$username = $usernameParts[1];
if (!strpos($username, '@') && !empty($usernameParts[0])) {
$username .= '@' . $usernameParts[0];
}
}
return $username;
}
/**
* Authentication request from the ngx_http_auth_request_module
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\Response The response
*/
public function httpauth(Request $request)
{
/**
Php-Auth-Pw: simple123
Php-Auth-User: john@kolab.org
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: cross-site
Sec-Gpc: 1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:93.0) Gecko/20100101 Firefox/93.0
X-Forwarded-For: 31.10.153.58
X-Forwarded-Proto: https
X-Original-Uri: /iRony/
X-Real-Ip: 31.10.153.58
*/
$username = $this->normalizeUsername($request->headers->get('Php-Auth-User', ""));
$password = $request->headers->get('Php-Auth-Pw', null);
if (empty($username)) {
//Allow unauthenticated requests
return response("");
}
if (empty($password)) {
\Log::debug("Authentication attempt failed: Empty password provided.");
return response("", 401);
}
try {
$this->authorizeRequest(
$username,
$password,
$request->headers->get('X-Real-Ip', null),
);
} catch (\Exception $e) {
\Log::debug("Authentication attempt failed: {$e->getMessage()}");
return response("", 403);
}
\Log::debug("Authentication attempt succeeded");
return response("");
}
/**
* Authentication request from the cyrus sasl
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\Response The response
*/
public function cyrussasl(Request $request)
{
$data = $request->getContent();
// Assumes "%u %r %p" as form data in the cyrus sasl config file
$array = explode(' ', rawurldecode($data));
if (count($array) != 3) {
\Log::debug("Authentication attempt failed: invalid data provided.");
return response("", 403);
}
$username = $array[0];
$realm = $array[1];
$password = $array[2];
if (!empty($realm)) {
$username = "$username@$realm";
}
if (empty($password)) {
\Log::debug("Authentication attempt failed: Empty password provided.");
return response("", 403);
}
try {
$this->authorizeRequestCredentialsOnly(
$username,
$password
);
} catch (\Exception $e) {
\Log::debug("Authentication attempt failed for $username: {$e->getMessage()}");
return response("", 403);
}
\Log::debug("Authentication attempt succeeded for $username");
return response("");
}
/**
* Authentication request.
*
* @todo: Separate IMAP(+STARTTLS) from IMAPS, same for SMTP/submission. =>
* I suppose that's not necessary given that we have the information avialable in the headers?
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\Response The response
*/
public function authenticate(Request $request)
{
/**
* Auth-Login-Attempt: 1
* Auth-Method: plain
* Auth-Pass: simple123
* Auth-Protocol: imap
* Auth-Ssl: on
* Auth-User: john@kolab.org
* Client-Ip: 127.0.0.1
* Host: 127.0.0.1
*
* Auth-SSL: on
* Auth-SSL-Verify: SUCCESS
* Auth-SSL-Subject: /CN=example.com
* Auth-SSL-Issuer: /CN=example.com
* Auth-SSL-Serial: C07AD56B846B5BFF
* Auth-SSL-Fingerprint: 29d6a80a123d13355ed16b4b04605e29cb55a5ad
*/
$password = $request->headers->get('Auth-Pass', null);
$username = $request->headers->get('Auth-User', null);
$ip = $request->headers->get('Client-Ip', null);
try {
$user = $this->authorizeRequest(
$username,
$password,
$ip,
);
} catch (\Exception $e) {
return $this->byebye($request, $e->getMessage());
}
// All checks passed
switch ($request->headers->get('Auth-Protocol')) {
case "imap":
return $this->authenticateIMAP($request, (bool) $user->getSetting('guam_enabled'), $password);
case "smtp":
return $this->authenticateSMTP($request, $password);
default:
return $this->byebye($request, "unknown protocol in request");
}
}
/**
* Authentication request for roundcube imap.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\Response The response
*/
public function authenticateRoundcube(Request $request)
{
/**
* Auth-Login-Attempt: 1
* Auth-Method: plain
* Auth-Pass: simple123
* Auth-Protocol: imap
* Auth-Ssl: on
* Auth-User: john@kolab.org
* Client-Ip: 127.0.0.1
* Host: 127.0.0.1
*
* Auth-SSL: on
* Auth-SSL-Verify: SUCCESS
* Auth-SSL-Subject: /CN=example.com
* Auth-SSL-Issuer: /CN=example.com
* Auth-SSL-Serial: C07AD56B846B5BFF
* Auth-SSL-Fingerprint: 29d6a80a123d13355ed16b4b04605e29cb55a5ad
*/
$password = $request->headers->get('Auth-Pass', null);
$username = $request->headers->get('Auth-User', null);
$ip = $request->headers->get('Proxy-Protocol-Addr', null);
try {
$user = $this->authorizeRequest(
$username,
$password,
$ip,
);
} catch (\Exception $e) {
return $this->byebye($request, $e->getMessage());
}
// All checks passed
switch ($request->headers->get('Auth-Protocol')) {
case "imap":
return $this->authenticateIMAP($request, false, $password);
default:
return $this->byebye($request, "unknown protocol in request");
}
}
/**
* Create an imap authentication response.
*
* @param \Illuminate\Http\Request $request The API request.
- * @param bool $prefGuam Wether or not guam is enabled.
+ * @param bool $prefGuam Whether or not Guam is enabled.
* @param string $password The password to include in the response.
*
* @return \Illuminate\Http\Response The response
*/
private function authenticateIMAP(Request $request, $prefGuam, $password)
{
if ($prefGuam) {
$port = \config('imap.guam_port');
} else {
$port = \config('imap.imap_port');
}
$response = response("")->withHeaders(
[
"Auth-Status" => "OK",
"Auth-Server" => \config('imap.host'),
"Auth-Port" => $port,
"Auth-Pass" => $password
]
);
return $response;
}
/**
* Create an smtp authentication response.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $password The password to include in the response.
*
* @return \Illuminate\Http\Response The response
*/
private function authenticateSMTP(Request $request, $password)
{
$response = response("")->withHeaders(
[
"Auth-Status" => "OK",
"Auth-Server" => \config('smtp.host'),
"Auth-Port" => \config('smtp.port'),
"Auth-Pass" => $password
]
);
return $response;
}
/**
* Create a failed-authentication response.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $reason The reason for the failure.
*
* @return \Illuminate\Http\Response The response
*/
private function byebye(Request $request, $reason = null)
{
\Log::debug("Byebye: {$reason}");
$response = response("")->withHeaders(
[
"Auth-Status" => "authentication failure",
"Auth-Wait" => 3
]
);
return $response;
}
}
diff --git a/src/app/User.php b/src/app/User.php
index f6038ee3..f20346d5 100644
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -1,760 +1,786 @@
<?php
namespace App;
+use App\AuthAttempt;
use App\Traits\AliasesTrait;
use App\Traits\BelongsToTenantTrait;
use App\Traits\EntitleableTrait;
use App\Traits\EmailPropertyTrait;
use App\Traits\UserConfigTrait;
use App\Traits\UuidIntKeyTrait;
use App\Traits\SettingsTrait;
use App\Traits\StatusPropertyTrait;
use Dyrynda\Database\Support\NullableFields;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Passport\HasApiTokens;
use League\OAuth2\Server\Exception\OAuthServerException;
/**
* The eloquent definition of a User.
*
* @property string $email
* @property int $id
* @property string $password
* @property string $password_ldap
* @property string $role
* @property int $status
* @property int $tenant_id
*/
class User extends Authenticatable
{
use AliasesTrait;
use BelongsToTenantTrait;
use EntitleableTrait;
use EmailPropertyTrait;
use HasApiTokens;
use NullableFields;
use UserConfigTrait;
use UuidIntKeyTrait;
use SettingsTrait;
use SoftDeletes;
use StatusPropertyTrait;
// a new user, default on creation
public const STATUS_NEW = 1 << 0;
// it's been activated
public const STATUS_ACTIVE = 1 << 1;
// user has been suspended
public const STATUS_SUSPENDED = 1 << 2;
// user has been deleted
public const STATUS_DELETED = 1 << 3;
// user has been created in LDAP
public const STATUS_LDAP_READY = 1 << 4;
// user mailbox has been created in IMAP
public const STATUS_IMAP_READY = 1 << 5;
// user in "limited feature-set" state
public const STATUS_DEGRADED = 1 << 6;
/** @var array<int, string> The attributes that are mass assignable */
protected $fillable = [
'id',
'email',
'password',
'password_ldap',
'status',
];
/** @var array<int, string> The attributes that should be hidden for arrays */
protected $hidden = [
'password',
'password_ldap',
'role'
];
/** @var array<int, string> The attributes that can be null */
protected $nullable = [
'password',
'password_ldap'
];
/** @var array<string, string> The attributes that should be cast */
protected $casts = [
'created_at' => 'datetime:Y-m-d H:i:s',
'deleted_at' => 'datetime:Y-m-d H:i:s',
'updated_at' => 'datetime:Y-m-d H:i:s',
];
/**
* Any wallets on which this user is a controller.
*
* This does not include wallets owned by the user.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function accounts()
{
return $this->belongsToMany(
Wallet::class, // The foreign object definition
'user_accounts', // The table name
'user_id', // The local foreign key
'wallet_id' // The remote foreign key
);
}
/**
* Assign a package to a user. The user should not have any existing entitlements.
*
* @param \App\Package $package The package to assign.
* @param \App\User|null $user Assign the package to another user.
*
* @return \App\User
*/
public function assignPackage($package, $user = null)
{
if (!$user) {
$user = $this;
}
return $user->assignPackageAndWallet($package, $this->wallets()->first());
}
/**
* Assign a package plan to a user.
*
* @param \App\Plan $plan The plan to assign
* @param \App\Domain $domain Optional domain object
*
* @return \App\User Self
*/
public function assignPlan($plan, $domain = null): User
{
$this->setSetting('plan_id', $plan->id);
foreach ($plan->packages as $package) {
if ($package->isDomain()) {
$domain->assignPackage($package, $this);
} else {
$this->assignPackage($package);
}
}
return $this;
}
/**
* Check if current user can delete another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canDelete($object): bool
{
if (!method_exists($object, 'wallet')) {
return false;
}
$wallet = $object->wallet();
// TODO: For now controller can delete/update the account owner,
// this may change in future, controllers are not 0-regression feature
return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet));
}
/**
* Check if current user can read data of another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canRead($object): bool
{
if ($this->role == 'admin') {
return true;
}
if ($object instanceof User && $this->id == $object->id) {
return true;
}
if ($this->role == 'reseller') {
if ($object instanceof User && $object->role == 'admin') {
return false;
}
if ($object instanceof Wallet && !empty($object->owner)) {
$object = $object->owner;
}
return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id;
}
if ($object instanceof Wallet) {
return $object->user_id == $this->id || $object->controllers->contains($this);
}
if (!method_exists($object, 'wallet')) {
return false;
}
$wallet = $object->wallet();
return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet));
}
/**
* Check if current user can update data of another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canUpdate($object): bool
{
if ($object instanceof User && $this->id == $object->id) {
return true;
}
if ($this->role == 'admin') {
return true;
}
if ($this->role == 'reseller') {
if ($object instanceof User && $object->role == 'admin') {
return false;
}
if ($object instanceof Wallet && !empty($object->owner)) {
$object = $object->owner;
}
return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id;
}
return $this->canDelete($object);
}
/**
* Degrade the user
*
* @return void
*/
public function degrade(): void
{
if ($this->isDegraded()) {
return;
}
$this->status |= User::STATUS_DEGRADED;
$this->save();
}
/**
* List the domains to which this user is entitled.
*
* @param bool $with_accounts Include domains assigned to wallets
* the current user controls but not owns.
* @param bool $with_public Include active public domains (for the user tenant).
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function domains($with_accounts = true, $with_public = true)
{
$domains = $this->entitleables(Domain::class, $with_accounts);
if ($with_public) {
$domains->orWhere(function ($query) {
if (!$this->tenant_id) {
$query->where('tenant_id', $this->tenant_id);
} else {
$query->withEnvTenantContext();
}
$query->where('domains.type', '&', Domain::TYPE_PUBLIC)
->where('domains.status', '&', Domain::STATUS_ACTIVE);
});
}
return $domains;
}
/**
* Return entitleable objects of a specified type controlled by the current user.
*
* @param string $class Object class
* @param bool $with_accounts Include objects assigned to wallets
* the current user controls, but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
private function entitleables(string $class, bool $with_accounts = true)
{
$wallets = $this->wallets()->pluck('id')->all();
if ($with_accounts) {
$wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all());
}
$object = new $class();
$table = $object->getTable();
return $object->select("{$table}.*")
->whereExists(function ($query) use ($table, $wallets, $class) {
$query->select(DB::raw(1))
->from('entitlements')
->whereColumn('entitleable_id', "{$table}.id")
->whereIn('entitlements.wallet_id', $wallets)
->where('entitlements.entitleable_type', $class);
});
}
/**
* Helper to find user by email address, whether it is
* main email address, alias or an external email.
*
* If there's more than one alias NULL will be returned.
*
* @param string $email Email address
* @param bool $external Search also for an external email
*
* @return \App\User|null User model object if found
*/
public static function findByEmail(string $email, bool $external = false): ?User
{
if (strpos($email, '@') === false) {
return null;
}
$email = \strtolower($email);
$user = self::where('email', $email)->first();
if ($user) {
return $user;
}
$aliases = UserAlias::where('alias', $email)->get();
if (count($aliases) == 1) {
return $aliases->first()->user;
}
// TODO: External email
return null;
}
/**
* Storage items for this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function fsItems()
{
return $this->hasMany(Fs\Item::class);
}
/**
* Return groups controlled by the current user.
*
* @param bool $with_accounts Include groups assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function groups($with_accounts = true)
{
return $this->entitleables(Group::class, $with_accounts);
}
/**
* Returns whether this user (or its wallet owner) is degraded.
*
* @param bool $owner Check also the wallet owner instead just the user himself
*
* @return bool
*/
public function isDegraded(bool $owner = false): bool
{
if ($this->status & self::STATUS_DEGRADED) {
return true;
}
if ($owner && ($wallet = $this->wallet())) {
return $wallet->owner && $wallet->owner->isDegraded();
}
return false;
}
/**
* A shortcut to get the user name.
*
* @param bool $fallback Return "<aa.name> User" if there's no name
*
* @return string Full user name
*/
public function name(bool $fallback = false): string
{
$settings = $this->getSettings(['first_name', 'last_name']);
$name = trim($settings['first_name'] . ' ' . $settings['last_name']);
if (empty($name) && $fallback) {
return trim(\trans('app.siteuser', ['site' => Tenant::getConfig($this->tenant_id, 'app.name')]));
}
return $name;
}
/**
* Old passwords for this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function passwords()
{
return $this->hasMany(UserPassword::class);
}
/**
* Return resources controlled by the current user.
*
* @param bool $with_accounts Include resources assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function resources($with_accounts = true)
{
return $this->entitleables(Resource::class, $with_accounts);
}
/**
* Return rooms controlled by the current user.
*
* @param bool $with_accounts Include rooms assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function rooms($with_accounts = true)
{
return $this->entitleables(Meet\Room::class, $with_accounts);
}
/**
* Return shared folders controlled by the current user.
*
* @param bool $with_accounts Include folders assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function sharedFolders($with_accounts = true)
{
return $this->entitleables(SharedFolder::class, $with_accounts);
}
public function senderPolicyFrameworkWhitelist($clientName)
{
$setting = $this->getSetting('spf_whitelist');
if (!$setting) {
return false;
}
$whitelist = json_decode($setting);
$matchFound = false;
foreach ($whitelist as $entry) {
if (substr($entry, 0, 1) == '/') {
$match = preg_match($entry, $clientName);
if ($match) {
$matchFound = true;
}
continue;
}
if (substr($entry, 0, 1) == '.') {
if (substr($clientName, (-1 * strlen($entry))) == $entry) {
$matchFound = true;
}
continue;
}
if ($entry == $clientName) {
$matchFound = true;
continue;
}
}
return $matchFound;
}
/**
* Un-degrade this user.
*
* @return void
*/
public function undegrade(): void
{
if (!$this->isDegraded()) {
return;
}
$this->status ^= User::STATUS_DEGRADED;
$this->save();
}
/**
* Return users controlled by the current user.
*
* @param bool $with_accounts Include users assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function users($with_accounts = true)
{
return $this->entitleables(User::class, $with_accounts);
}
/**
* Verification codes for this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function verificationcodes()
{
return $this->hasMany(VerificationCode::class, 'user_id', 'id');
}
/**
* Wallets this user owns.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function wallets()
{
return $this->hasMany(Wallet::class);
}
/**
* User password mutator
*
* @param string $password The password in plain text.
*
* @return void
*/
public function setPasswordAttribute($password)
{
if (!empty($password)) {
$this->attributes['password'] = Hash::make($password);
$this->attributes['password_ldap'] = '{SSHA512}' . base64_encode(
pack('H*', hash('sha512', $password))
);
}
}
/**
* User LDAP password mutator
*
* @param string $password The password in plain text.
*
* @return void
*/
public function setPasswordLdapAttribute($password)
{
$this->setPasswordAttribute($password);
}
/**
* User status mutator
*
* @throws \Exception
*/
public function setStatusAttribute($status)
{
$new_status = 0;
$allowed_values = [
self::STATUS_NEW,
self::STATUS_ACTIVE,
self::STATUS_SUSPENDED,
self::STATUS_DELETED,
self::STATUS_LDAP_READY,
self::STATUS_IMAP_READY,
self::STATUS_DEGRADED,
];
foreach ($allowed_values as $value) {
if ($status & $value) {
$new_status |= $value;
$status ^= $value;
}
}
if ($status > 0) {
throw new \Exception("Invalid user status: {$status}");
}
$this->attributes['status'] = $new_status;
}
/**
* Validate the user credentials
*
* @param string $username The username.
* @param string $password The password in plain text.
* @param bool $updatePassword Store the password if currently empty
*
* @return bool true on success
*/
public function validateCredentials(string $username, string $password, bool $updatePassword = true): bool
{
$authenticated = false;
if ($this->email === \strtolower($username)) {
if (!empty($this->password)) {
if (Hash::check($password, $this->password)) {
$authenticated = true;
}
} elseif (!empty($this->password_ldap)) {
if (substr($this->password_ldap, 0, 6) == "{SSHA}") {
$salt = substr(base64_decode(substr($this->password_ldap, 6)), 20);
$hash = '{SSHA}' . base64_encode(
sha1($password . $salt, true) . $salt
);
if ($hash == $this->password_ldap) {
$authenticated = true;
}
} elseif (substr($this->password_ldap, 0, 9) == "{SSHA512}") {
$salt = substr(base64_decode(substr($this->password_ldap, 9)), 64);
$hash = '{SSHA512}' . base64_encode(
pack('H*', hash('sha512', $password . $salt)) . $salt
);
if ($hash == $this->password_ldap) {
$authenticated = true;
}
}
} else {
\Log::error("Incomplete credentials for {$this->email}");
}
}
if ($authenticated) {
- \Log::info("Successful authentication for {$this->email}");
-
// TODO: update last login time
if ($updatePassword && (empty($this->password) || empty($this->password_ldap))) {
$this->password = $password;
$this->save();
}
- } else {
- // TODO: Try actual LDAP?
- \Log::info("Authentication failed for {$this->email}");
}
return $authenticated;
}
/**
* Validate request location regarding geo-lockin
*
* @param string $ip IP address to check, usually request()->ip()
*
* @return bool
*/
public function validateLocation($ip): bool
{
$countryCodes = json_decode($this->getSetting('limit_geo', "[]"));
if (empty($countryCodes)) {
return true;
}
return in_array(\App\Utils::countryForIP($ip), $countryCodes);
}
/**
* Retrieve and authenticate a user
*
- * @param string $username The username.
- * @param string $password The password in plain text.
- * @param string $secondFactor The second factor (secondfactor from current request is used as fallback).
+ * @param string $username The username
+ * @param string $password The password in plain text
+ * @param ?string $clientIP The IP address of the client
*
* @return array ['user', 'reason', 'errorMessage']
*/
- public static function findAndAuthenticate($username, $password, $secondFactor = null): ?array
+ public static function findAndAuthenticate($username, $password, $clientIP = null): array
{
- $user = User::where('email', $username)->first();
+ $error = null;
- // TODO: 'reason' below could be AuthAttempt::REASON_*
- // TODO: $secondFactor argument is not used anywhere
+ if (!$clientIP) {
+ $clientIP = request()->ip();
+ }
+
+ $user = User::where('email', $username)->first();
if (!$user) {
- return ['reason' => 'notfound', 'errorMessage' => "User not found."];
+ $error = AuthAttempt::REASON_NOTFOUND;
}
- if (!$user->validateCredentials($username, $password)) {
- return ['reason' => 'credentials', 'errorMessage' => "Invalid password."];
+ // Check user password
+ if (!$error && !$user->validateCredentials($username, $password)) {
+ $error = AuthAttempt::REASON_PASSWORD;
}
- if (!$user->validateLocation(request()->ip())) {
- return ['reason' => 'geolocation', 'errorMessage' => "Country code mismatch."];
+ // Check user (request) location
+ if (!$error && !$user->validateLocation($clientIP)) {
+ $error = AuthAttempt::REASON_GEOLOCATION;
}
- if (!$secondFactor) {
- // Check the request if there is a second factor provided
- // as fallback.
- $secondFactor = request()->secondfactor;
+ // Check 2FA
+ if (!$error) {
+ try {
+ (new \App\Auth\SecondFactor($user))->validate(request()->secondfactor);
+ } catch (\Exception $e) {
+ $error = AuthAttempt::REASON_2FA_GENERIC;
+ $message = $e->getMessage();
+ }
}
- try {
- (new \App\Auth\SecondFactor($user))->validate($secondFactor);
- } catch (\Exception $e) {
- return ['reason' => 'secondfactor', 'errorMessage' => $e->getMessage()];
+ // Check 2FA - Companion App
+ if (!$error && \App\CompanionApp::where('user_id', $user->id)->exists()) {
+ $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
+ if (!$attempt->waitFor2FA()) {
+ $error = AuthAttempt::REASON_2FA;
+ }
}
+ if ($error) {
+ if ($user && empty($attempt)) {
+ $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
+ if (!$attempt->isAccepted()) {
+ $attempt->deny($error);
+ $attempt->save();
+ $attempt->notify();
+ }
+ }
+
+ if ($user) {
+ \Log::info("Authentication failed for {$user->email}");
+ }
+
+ return ['reason' => $error, 'errorMessage' => $message ?? \trans("auth.error.{$error}")];
+ }
+
+ \Log::info("Successful authentication for {$user->email}");
+
return ['user' => $user];
}
/**
* Hook for passport
*
* @throws \Throwable
*
* @return \App\User User model object if found
*/
- public function findAndValidateForPassport($username, $password): User
+ public static function findAndValidateForPassport($username, $password): User
{
$result = self::findAndAuthenticate($username, $password);
if (isset($result['reason'])) {
- // TODO: Shouldn't we create AuthAttempt record here?
-
- if ($result['reason'] == 'secondfactor') {
+ if ($result['reason'] == AuthAttempt::REASON_2FA_GENERIC) {
// This results in a json response of {'error': 'secondfactor', 'error_description': '$errorMessage'}
throw new OAuthServerException($result['errorMessage'], 6, 'secondfactor', 401);
}
+ // TODO: Display specific error message if 2FA via Companion App was expected?
+
throw OAuthServerException::invalidCredentials();
}
return $result['user'];
}
}
diff --git a/src/resources/lang/en/auth.php b/src/resources/lang/en/auth.php
index eb23c93f..3418d0d6 100644
--- a/src/resources/lang/en/auth.php
+++ b/src/resources/lang/en/auth.php
@@ -1,21 +1,27 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'Invalid username or password.',
'password' => 'The provided password is incorrect.',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
'logoutsuccess' => 'Successfully logged out.',
+ 'error.password' => "Invalid password",
+ 'error.geolocation' => "Country code mismatch",
+ 'error.nofound' => "User not found",
+ 'error.2fa' => "Second factor failure",
+ 'error.2fa-generic' => "Second factor failure",
+
];

File Metadata

Mime Type
text/x-diff
Expires
Sat, Jun 28, 5:45 AM (7 h, 43 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
201417
Default Alt Text
(45 KB)

Event Timeline