Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F262027
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
45 KB
Referenced Files
None
Subscribers
None
View Options
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
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Jun 28, 5:45 AM (12 h, 2 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
201417
Default Alt Text
(45 KB)
Attached To
Mode
R2 kolab
Attached
Detach File
Event Timeline
Log In to Comment