OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
184
app/Domain/Auth/Services/AccessToken.php
Normal file
184
app/Domain/Auth/Services/AccessToken.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Services;
|
||||
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Validation\UnauthorizedException;
|
||||
use Laravel\Sanctum\Contracts\HasAbilities;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Repositories\AccessTokenRepository;
|
||||
|
||||
class AccessToken implements HasAbilities
|
||||
{
|
||||
use HasApiTokens, \Illuminate\Auth\Authenticatable;
|
||||
|
||||
public ?int $id = null;
|
||||
|
||||
public string $tokenableType;
|
||||
|
||||
public int $tokenableId;
|
||||
|
||||
public string $name;
|
||||
|
||||
public string $token;
|
||||
|
||||
public array $abilities;
|
||||
|
||||
public ?DateTimeInterface $lastUsedAt;
|
||||
|
||||
public ?DateTimeInterface $expires_at;
|
||||
|
||||
public ?DateTimeInterface $created_at;
|
||||
|
||||
public ?DateTimeInterface $updatedAt;
|
||||
|
||||
public AuthUser $tokenable;
|
||||
|
||||
protected AccessTokenRepository $tokenRepo;
|
||||
|
||||
public function __construct(
|
||||
array $attributes = [],
|
||||
) {
|
||||
foreach ($attributes as $key => $value) {
|
||||
if (property_exists($this, $key)) {
|
||||
$this->$key = $value;
|
||||
}
|
||||
}
|
||||
$this->tokenRepo = app()->make(AccessTokenRepository::class);
|
||||
$this->tokenable = app()->make(AuthUser::class);
|
||||
|
||||
$this->abilities = $this->abilities ?? ['*'];
|
||||
}
|
||||
|
||||
public function can($ability): bool
|
||||
{
|
||||
return in_array('*', $this->abilities) ||
|
||||
array_key_exists($ability, array_flip($this->abilities));
|
||||
}
|
||||
|
||||
public function cant($ability): bool
|
||||
{
|
||||
return ! $this->can($ability);
|
||||
}
|
||||
|
||||
public function createToken($userId, $name = null)
|
||||
{
|
||||
|
||||
if ($userId == session('userdata.id') || Auth::userIsAtLeast(Roles::$admin)) {
|
||||
|
||||
$token = $this->tokenRepo->createToken($userId, $name ?? 'personal-token');
|
||||
|
||||
return (object) $token;
|
||||
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function findToken($token)
|
||||
{
|
||||
$tokenObject = new self;
|
||||
$tokenData = $tokenObject->tokenRepo->findToken($token);
|
||||
|
||||
if (empty($tokenData)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tokenObject->id = $tokenData['id'];
|
||||
$tokenObject->expires_at = ! empty($tokenData['expires_at']) ? dtHelper()->parseDbDateTime($tokenData['expires_at']) : null;
|
||||
$tokenObject->created_at = ! empty($tokenData['created_at']) ? dtHelper()->parseDbDateTime($tokenData['created_at']) : null;
|
||||
|
||||
$tokenObject->tokenable->setUser($tokenData['tokenable_id']);
|
||||
|
||||
return $tokenObject;
|
||||
}
|
||||
|
||||
public function getConnection()
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function forceFill()
|
||||
{
|
||||
$this->tokenRepo->updateLastUsedAt($this->id);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getUserTokens(int $userId)
|
||||
{
|
||||
|
||||
if (Auth::userIsAtLeast(Roles::$admin) || $userId == session('userdata.id')) {
|
||||
|
||||
return $this->tokenRepo->getAllTokensByUserId($userId) ?? [];
|
||||
|
||||
} else {
|
||||
|
||||
throw new UnauthorizedException('You are not authorized to access this resource.');
|
||||
}
|
||||
}
|
||||
|
||||
public function getTokenById($tokenId)
|
||||
{
|
||||
return $this->tokenRepo->findTokenById($tokenId);
|
||||
}
|
||||
|
||||
public function deleteToken(int $tokenId)
|
||||
{
|
||||
|
||||
$token = $this->getTokenById($tokenId);
|
||||
|
||||
if (Auth::userIsAtLeast(Roles::$admin) || $token['tokenable_id'] == session('userdata.id')) {
|
||||
|
||||
return $this->tokenRepo->deleteToken($tokenId);
|
||||
|
||||
} else {
|
||||
|
||||
throw new UnauthorizedException('You are not authorized to access this resource.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the bearer token used to authenticate the current request.
|
||||
* Designed for client-side sign-out flows (mobile, third-party
|
||||
* integrations) that need a server-side invalidation rather than
|
||||
* just clearing local credentials.
|
||||
*
|
||||
* Uses the request's Authorization header to identify the token —
|
||||
* caller doesn't need to track its own token id. Defense-in-depth
|
||||
* check confirms the matched token belongs to the session user
|
||||
* (the middleware should already guarantee this since the bearer
|
||||
* is what populated the session, but the explicit check guards
|
||||
* against any odd state).
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function revokeCurrentToken(): bool
|
||||
{
|
||||
$request = app(\Leantime\Core\Http\ApiRequest::class);
|
||||
$bearer = $request->getBearerToken();
|
||||
if (! $bearer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$token = $this->tokenRepo->findToken($bearer);
|
||||
if (! $token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sessionUserId = (int) session('userdata.id');
|
||||
if ($sessionUserId === 0 || (int) $token['tokenable_id'] !== $sessionUserId) {
|
||||
// Bearer matched a row that isn't this session's user —
|
||||
// refuse rather than risk deleting someone else's token.
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->tokenRepo->deleteToken((int) $token['id']);
|
||||
}
|
||||
}
|
||||
772
app/Domain/Auth/Services/Auth.php
Normal file
772
app/Domain/Auth/Services/Auth.php
Normal file
@@ -0,0 +1,772 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Services;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Session\SessionManager;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Leantime\Core\Configuration\Environment as EnvironmentCore;
|
||||
use Leantime\Core\Controller\Frontcontroller as FrontcontrollerCore;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Core\Mailer as MailerCore;
|
||||
use Leantime\Core\UI\Theme;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Repositories\AccessTokenRepository;
|
||||
use Leantime\Domain\Auth\Repositories\Auth as AuthRepository;
|
||||
use Leantime\Domain\Ldap\Services\Ldap;
|
||||
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use RobThree\Auth\TwoFactorAuth;
|
||||
|
||||
class Auth implements Authenticatable
|
||||
{
|
||||
use DispatchesEvents, HasApiTokens, \Illuminate\Auth\Authenticatable;
|
||||
|
||||
/**
|
||||
* @var int|null user id from DB
|
||||
*/
|
||||
private ?int $userId = null;
|
||||
|
||||
private ?string $password = null;
|
||||
|
||||
private ?SessionManager $session = null;
|
||||
|
||||
/**
|
||||
* @var string userrole (admin, client, employee)
|
||||
*/
|
||||
public string $role = '';
|
||||
|
||||
public array $settings = [];
|
||||
|
||||
/**
|
||||
* @var int time for cookie
|
||||
*/
|
||||
public mixed $cookieTime;
|
||||
|
||||
public string $error = '';
|
||||
|
||||
public string $success = '';
|
||||
|
||||
public string|bool $resetInProgress = false;
|
||||
|
||||
/**
|
||||
* How often can a user reset a password before it has to be changed
|
||||
*/
|
||||
public int $pwResetLimit = 5;
|
||||
|
||||
private EnvironmentCore $config;
|
||||
|
||||
public LanguageCore $language;
|
||||
|
||||
public SettingRepository $settingsRepo;
|
||||
|
||||
public AuthRepository $authRepo;
|
||||
|
||||
public UserRepository $userRepo;
|
||||
|
||||
private AccessTokenRepository $tokenRepo;
|
||||
|
||||
/**
|
||||
* __construct - getInstance of session and get sessionId and refers to login if post is set
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function __construct(
|
||||
EnvironmentCore $config,
|
||||
?SessionManager $session,
|
||||
LanguageCore $language,
|
||||
SettingRepository $settingsRepo,
|
||||
AuthRepository $authRepo,
|
||||
UserRepository $userRepo,
|
||||
AccessTokenRepository $tokenRepo
|
||||
) {
|
||||
$this->config = $config;
|
||||
$this->session = $session;
|
||||
$this->language = $language;
|
||||
$this->settingsRepo = $settingsRepo;
|
||||
$this->authRepo = $authRepo;
|
||||
$this->userRepo = $userRepo;
|
||||
$this->tokenRepo = $tokenRepo;
|
||||
|
||||
$this->cookieTime = $this->config->sessionExpiration;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|bool returns role as string or false on failure
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public static function getRoleToCheck(bool $forceGlobalRoleCheck): string|bool
|
||||
{
|
||||
if (session()->exists('userdata') === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($forceGlobalRoleCheck) {
|
||||
$roleToCheck = session('userdata.role');
|
||||
// If projectRole is not defined or if it is set to inherited
|
||||
} elseif (! session()->exists('userdata.projectRole') || session('userdata.projectRole') == 'inherited' || session('userdata.projectRole') == '') {
|
||||
$roleToCheck = session('userdata.role');
|
||||
// Do not overwrite admin or owner roles
|
||||
} elseif (session('userdata.role') == Roles::$owner || session('userdata.role') == Roles::$admin || session('userdata.role') == Roles::$manager) {
|
||||
$roleToCheck = session('userdata.role');
|
||||
// In all other cases check the project role
|
||||
} else {
|
||||
$roleToCheck = session('userdata.projectRole');
|
||||
}
|
||||
|
||||
// Ensure the role is a valid role. An unresolvable role here makes the permission engine
|
||||
// deny EVERYTHING (every #[RequiresPermission] check fails) — so log it loudly with
|
||||
// context. This exact breadcrumb ("invalid role detected: 50") is what surfaced the 3.9.x
|
||||
// Bearer regression where a session stored the raw role int instead of its name string.
|
||||
if (in_array($roleToCheck, Roles::getRoles()) === false) {
|
||||
|
||||
Log::warning('Invalid role in session — authorization will deny everything. Resolved role: '.var_export($roleToCheck, true).' (user '.(session('userdata.id') ?? 'guest').'). Expected one of: '.implode(', ', Roles::getRoles()));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $roleToCheck;
|
||||
}
|
||||
|
||||
/**
|
||||
* login - Validate POST-data with DB
|
||||
*
|
||||
*
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function login(string $username, string $password): bool
|
||||
{
|
||||
self::dispatch_event('beforeLoginCheck', ['username' => $username, 'password' => $password]);
|
||||
|
||||
// different identity providers can live here
|
||||
// they all need to
|
||||
// // A: ensure the user is in leantime (with a valid role) and if not create the user
|
||||
// // B: set the session variables
|
||||
// // C: update users from the identity provider,
|
||||
// Try Ldap
|
||||
if ($this->config->useLdap === true && extension_loaded('ldap')) {
|
||||
$ldap = app()->make(Ldap::class);
|
||||
|
||||
if ($ldap->connect() && $ldap->bind($username, $password)) {
|
||||
// Update username to include domain
|
||||
$usernameWDomain = $ldap->getEmail($username);
|
||||
// Get user
|
||||
$user = $this->userRepo->getUserByEmail($usernameWDomain);
|
||||
|
||||
$ldapUser = $ldap->getSingleUser($username);
|
||||
|
||||
if ($ldapUser === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If user does not exist create user
|
||||
if (! $user) {
|
||||
$userArray = [
|
||||
'firstname' => $ldapUser['firstname'],
|
||||
'lastname' => $ldapUser['lastname'],
|
||||
'phone' => $ldapUser['phone'],
|
||||
'user' => $ldapUser['user'],
|
||||
'role' => $ldapUser['role'],
|
||||
'department' => $ldapUser['department'],
|
||||
'jobTitle' => $ldapUser['jobTitle'],
|
||||
'jobLevel' => $ldapUser['jobLevel'],
|
||||
'password' => '',
|
||||
'clientId' => '',
|
||||
'source' => 'ldap',
|
||||
'status' => 'a',
|
||||
];
|
||||
|
||||
$userId = $this->userRepo->addUser($userArray);
|
||||
|
||||
if ($userId !== false) {
|
||||
$user = $this->userRepo->getUserByEmail($usernameWDomain);
|
||||
} else {
|
||||
|
||||
Log::error('Ldap user creation failed.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// @TODO: create a better login response. This will return that the username or password was not correct
|
||||
} else {
|
||||
$user['firstname'] = $ldapUser['firstname'];
|
||||
$user['lastname'] = $ldapUser['lastname'];
|
||||
$user['phone'] = $ldapUser['phone'];
|
||||
$user['user'] = $user['username'];
|
||||
$user['department'] = $ldapUser['department'];
|
||||
$user['jobTitle'] = $ldapUser['jobTitle'];
|
||||
$user['jobLevel'] = $ldapUser['jobLevel'];
|
||||
|
||||
$this->userRepo->editUser($user, $user['id']);
|
||||
}
|
||||
|
||||
if ($user !== false && is_array($user)) {
|
||||
$this->setUserSession($user, true);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
|
||||
Log::info('Could not retrieve user by email');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't return false, to allow the standard login provider to check the db for contractors or clients not
|
||||
// in ldap
|
||||
} elseif ($this->config->useLdap === true && ! extension_loaded('ldap')) {
|
||||
Log::error("Can't use ldap. Extension not installed");
|
||||
}
|
||||
|
||||
// TODO: Single Sign On?
|
||||
// Standard login
|
||||
// Check if the user is in our db
|
||||
// Check even if ldap is turned on to allow contractors and clients to have an account
|
||||
$user = $this->authRepo->getUserByLogin($username, $password);
|
||||
|
||||
if ($user !== false && is_array($user)) {
|
||||
$this->setUserSession($user);
|
||||
|
||||
self::dispatch_event('afterLoginCheck', ['username' => $username, 'password' => $password, 'authService' => app()->make(self::class)]);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
$this->logFailedLogin($username);
|
||||
self::dispatch_event('afterLoginCheck', ['username' => $username, 'password' => $password, 'authService' => app()->make(self::class)]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new personal access token
|
||||
*/
|
||||
public function createToken(string $name, array $abilities = ['*']): array
|
||||
{
|
||||
if (! $this->loggedIn()) {
|
||||
throw new \Exception('User must be authenticated to create token');
|
||||
}
|
||||
|
||||
return $this->tokenRepo->createToken($this->getUserId(), $name, $abilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|void
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function setUserSession(mixed $user, bool $isExternalAuth = false)
|
||||
{
|
||||
if (! $user || ! is_array($user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Web-login session. twoFAVerified: false — the web flow enforces interactive 2FA via the
|
||||
// AuthCheck gate. Built via the shared factory (role NAME string + consistent fields), with
|
||||
// the web-only globalUserId added on top.
|
||||
$currentUser = UserSessionBuilder::build($user, isExternalAuth: $isExternalAuth, twoFAVerified: false);
|
||||
$currentUser['globalUserId'] = Uuid::uuid5(Uuid::NAMESPACE_DNS, strtolower($user['username']));
|
||||
|
||||
$currentUser = self::dispatch_filter('user_session_vars', $currentUser);
|
||||
|
||||
session(['userdata' => $currentUser]);
|
||||
session(['usersettings' => $currentUser['settings']]);
|
||||
|
||||
$this->updateUserSessionDB($currentUser['id'], session()->getId());
|
||||
|
||||
// Clear user theme cache on login
|
||||
Theme::clearCache();
|
||||
}
|
||||
|
||||
public function updateUserSessionDB(int $userId, string $sessionID): bool
|
||||
{
|
||||
return $this->authRepo->updateUserSession($userId, $sessionID, (string) time());
|
||||
}
|
||||
|
||||
/**
|
||||
* logged_in - Check if logged in and Update sessions
|
||||
*/
|
||||
public function loggedIn(): bool
|
||||
{
|
||||
// Check if we actually have a php session available
|
||||
if (session()->exists('userdata')) {
|
||||
return true;
|
||||
// If the session doesn't have any session data we are out of sync. Start again
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user is logged in.
|
||||
*
|
||||
* @return bool Returns true if the user is logged in, false otherwise.
|
||||
*/
|
||||
public static function isLoggedIn(): bool
|
||||
{
|
||||
|
||||
// Check if we actually have a php session available
|
||||
if (session()->exists('userdata')) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* logout - destroy sessions and cookies
|
||||
*
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function logout(): void
|
||||
{
|
||||
|
||||
$this->authRepo->invalidateSession($this->session->getId());
|
||||
|
||||
$sessionsToDestroy = self::dispatch_filter('sessions_vars_to_destroy', [
|
||||
'userdata',
|
||||
'template',
|
||||
'subdomainData',
|
||||
'currentProject',
|
||||
'currentSprint',
|
||||
'projectsettings',
|
||||
'currentSubscriptions',
|
||||
'lastTicketView',
|
||||
'lastFilteredTicketTableView',
|
||||
]);
|
||||
|
||||
foreach ($sessionsToDestroy as $key) {
|
||||
session()->forget($key);
|
||||
}
|
||||
|
||||
self::dispatch_event('afterSessionDestroy', ['authService' => app()->make(self::class)]);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* validateResetLink - validates that the password reset link belongs to a user account in the database
|
||||
*
|
||||
* @param string $hash invite link hash
|
||||
*/
|
||||
public function validateResetLink(string $hash): bool
|
||||
{
|
||||
|
||||
return $this->authRepo->validateResetLink($hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* getUserByInviteLink - gets the user by invite link
|
||||
*
|
||||
* @param string $hash invite link hash
|
||||
*/
|
||||
public function getUserByInviteLink(string $hash): bool|array
|
||||
{
|
||||
return $this->authRepo->getUserByInviteLink($hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* generateLinkAndSendEmail - generates an invitation link (hash) and sends email to user
|
||||
*
|
||||
* @param string $username new user to be invited (email)
|
||||
* @return bool returns true on success, false on failure
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function generateLinkAndSendEmail(string $username): bool
|
||||
{
|
||||
|
||||
$userFromDB = $this->userRepo->getUserByEmail($username);
|
||||
|
||||
if ($userFromDB !== false && count($userFromDB) > 0) {
|
||||
if ($userFromDB['pwResetCount'] < $this->pwResetLimit) {
|
||||
$permitted_chars = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
$resetLink = substr(str_shuffle($permitted_chars), 0, 32);
|
||||
|
||||
$result = $this->authRepo->setPWResetLink($username, $resetLink);
|
||||
|
||||
if ($result) {
|
||||
// Don't queue, send right away
|
||||
$mailer = app()->make(MailerCore::class);
|
||||
$mailer->setContext('password_reset');
|
||||
$mailer->setSubject($this->language->__('email_notifications.password_reset_subject'));
|
||||
$actual_link = ''.BASE_URL.'/auth/resetPw/'.$resetLink;
|
||||
$mailer->setHtml(sprintf($this->language->__('email_notifications.password_reset_message'), $actual_link));
|
||||
$to = [$username];
|
||||
$mailer->sendMail($to, 'Leantime System');
|
||||
|
||||
return true;
|
||||
}
|
||||
} elseif ($this->config->debug) {
|
||||
|
||||
Log::warning('PW reset failed: maximum request count has been reached for user '.$userFromDB['id']);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function changePw(string $password, string $hash): bool
|
||||
{
|
||||
return $this->authRepo->changePW($password, $hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* checkPasswordStrength - validates that a password meets the minimum strength requirements.
|
||||
*
|
||||
* Password must be at least 8 characters and include an upper case letter,
|
||||
* a lower case letter, a number and a special character.
|
||||
*
|
||||
* @param string $password the password to validate
|
||||
* @return bool returns true if the password is strong enough, false otherwise
|
||||
*/
|
||||
public function checkPasswordStrength(string $password): bool
|
||||
{
|
||||
$uppercase = preg_match('@[A-Z]@', $password);
|
||||
$lowercase = preg_match('@[a-z]@', $password);
|
||||
$number = preg_match('@[0-9]@', $password);
|
||||
$specialChars = preg_match('@[^\w]@', $password);
|
||||
|
||||
if (! $uppercase || ! $lowercase || ! $number || ! $specialChars || strlen($password) < 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* resetPassword - validates and applies a password reset for a given reset link.
|
||||
*
|
||||
* Performs the password match check, strength check and persists the new
|
||||
* password. Returns a status string the caller can map to a notification:
|
||||
* 'success', 'mismatch', 'weak' or 'error'.
|
||||
*
|
||||
* @param string $password the new password
|
||||
* @param string $passwordConfirm the password confirmation
|
||||
* @param string $hash the password reset link hash
|
||||
* @return string one of 'success', 'mismatch', 'weak', 'error'
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function resetPassword(string $password, string $passwordConfirm, string $hash): string
|
||||
{
|
||||
if (strlen($password) === 0 || $password !== $passwordConfirm) {
|
||||
return 'mismatch';
|
||||
}
|
||||
|
||||
if (! $this->checkPasswordStrength($password)) {
|
||||
return 'weak';
|
||||
}
|
||||
|
||||
if ($this->changePw($password, $hash)) {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
return 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* resolveSafeRedirect - resolves a user supplied redirect target into a safe,
|
||||
* application-internal absolute URL, guarding against open redirects.
|
||||
*
|
||||
* @param string|null $redirect the raw redirect target (typically from the request)
|
||||
* @return string an absolute URL that is safe to redirect to
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function resolveSafeRedirect(?string $redirect): string
|
||||
{
|
||||
$redirectUrl = BASE_URL.'/dashboard/home';
|
||||
|
||||
if ($redirect !== null && trim($redirect) !== '' && trim($redirect) !== '/') {
|
||||
// Normalize backslash-based protocol tricks (e.g. \/\/attacker.com)
|
||||
// to forward slashes before any checks.
|
||||
$url = str_replace('\\', '/', rawurldecode($redirect));
|
||||
|
||||
// Drop control characters and surrounding whitespace before any guard, so a
|
||||
// padded variant (" //evil.com", "%09//evil.com") can't slip past the checks
|
||||
// below and can't reach the Location header.
|
||||
$url = trim(preg_replace('/[\x00-\x1F\x7F]/', '', $url));
|
||||
|
||||
// Strip the application base URL when present so that same-origin
|
||||
// absolute URLs (e.g. https://my-leantime.com/dashboard/home) are
|
||||
// treated the same as their relative counterparts.
|
||||
//
|
||||
// Match only on a real boundary: a bare str_starts_with() would also fire on
|
||||
// https://hostile.com/pwn when BASE_URL is https://host, rewriting an external
|
||||
// URL into the bogus internal path /ile.com/pwn instead of rejecting it. The
|
||||
// same applies to subdirectory installs (BASE_URL /app vs a /application path).
|
||||
$base = rtrim(BASE_URL, '/');
|
||||
|
||||
if ($base !== '' && (
|
||||
$url === $base
|
||||
|| str_starts_with($url, $base.'/')
|
||||
|| str_starts_with($url, $base.'?')
|
||||
|| str_starts_with($url, $base.'#')
|
||||
)) {
|
||||
$url = substr($url, strlen($base));
|
||||
}
|
||||
|
||||
// Guard: protocol-relative URL (//attacker.com) — explicitly reject.
|
||||
// FILTER_VALIDATE_URL treats these as valid without a scheme, but
|
||||
// browsers resolve them to the current scheme, making them an open
|
||||
// redirect vector.
|
||||
if (str_starts_with($url, '//')) {
|
||||
return $redirectUrl;
|
||||
}
|
||||
|
||||
// Guard: external absolute URL — reject.
|
||||
// filter_var returns the URL (truthy) for well-formed absolute URLs
|
||||
// with a scheme; relative paths return false.
|
||||
if (filter_var($url, FILTER_VALIDATE_URL) !== false) {
|
||||
return $redirectUrl;
|
||||
}
|
||||
|
||||
// At this point $url is a relative path. Guard against an empty
|
||||
// path that could result from stripping a BASE_URL-only input.
|
||||
$url = ltrim($url, '/');
|
||||
|
||||
// Block redirect to logout — allowing a POST-login redirect to
|
||||
// /auth/logout would create a forced-logout loop. Compare the normalized
|
||||
// path so the query string, a trailing slash and casing can't be used to
|
||||
// walk around the block (/auth/logout/, /auth/logout?next=/x, /AUTH/logout).
|
||||
$path = rtrim(strtolower(strtok($url, '?#')), '/');
|
||||
|
||||
if ($url !== '' && $path !== 'auth/logout') {
|
||||
$redirectUrl = BASE_URL.'/'.$url;
|
||||
}
|
||||
}
|
||||
|
||||
return $redirectUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* shouldHideLoginForm - determines whether the default login form should be hidden,
|
||||
* combining the admin setting with the configured disableLoginForm flag.
|
||||
*
|
||||
* @return bool returns true if the default login form should be hidden
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function shouldHideLoginForm(): bool
|
||||
{
|
||||
$hideLogin = $this->settingsRepo->getSetting('auth.hideDefaultLogin');
|
||||
|
||||
if (! empty($hideLogin) && $hideLogin == 'on') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) $this->config->disableLoginForm;
|
||||
}
|
||||
|
||||
/**
|
||||
* getLoginInputPlaceholder - returns the translation key for the login input placeholder
|
||||
* depending on whether LDAP authentication is enabled.
|
||||
*
|
||||
* @return string the placeholder translation key
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getLoginInputPlaceholder(): string
|
||||
{
|
||||
if ($this->config->useLdap) {
|
||||
return 'input.placeholders.enter_email_or_username';
|
||||
}
|
||||
|
||||
return 'input.placeholders.enter_email';
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public static function userIsAtLeast(string $role, bool $forceGlobalRoleCheck = false): bool
|
||||
{
|
||||
|
||||
// Force Global Role check to circumvent projectRole checks for global controllers (users, projects, clients etc)
|
||||
$roleToCheck = self::getRoleToCheck($forceGlobalRoleCheck);
|
||||
|
||||
if ($roleToCheck === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$testKey = array_search($role, Roles::getRoles());
|
||||
|
||||
if ($role == '' || $testKey === false) {
|
||||
Log::warning('Check for invalid role detected: '.$role);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentUserKey = array_search($roleToCheck, Roles::getRoles());
|
||||
|
||||
if ($testKey <= $currentUserKey) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws HttpResponseException
|
||||
*/
|
||||
public static function authOrRedirect(array|string $role, bool $forceGlobalRoleCheck = false): bool
|
||||
{
|
||||
if (self::userHasRole($role, $forceGlobalRoleCheck)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new HttpResponseException(FrontcontrollerCore::redirect(BASE_URL.'/errors/error403'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public static function userHasRole(string|array $role, bool $forceGlobalRoleCheck = false): bool
|
||||
{
|
||||
|
||||
// Force Global Role check to circumvent projectRole checks for global controllers (users, projects, clients etc)
|
||||
$roleToCheck = self::getRoleToCheck($forceGlobalRoleCheck);
|
||||
|
||||
if (is_array($role) && in_array($roleToCheck, $role)) {
|
||||
return true;
|
||||
} elseif ($role == $roleToCheck) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function getRole(): void {}
|
||||
|
||||
public static function getUserClientId(): mixed
|
||||
{
|
||||
return session('userdata.clientId');
|
||||
}
|
||||
|
||||
public static function getUserId(): mixed
|
||||
{
|
||||
return session('userdata.id');
|
||||
}
|
||||
|
||||
public function use2FA(): mixed
|
||||
{
|
||||
return session('userdata.twoFAEnabled');
|
||||
}
|
||||
|
||||
public function verify2FA(string $code): bool
|
||||
{
|
||||
$twoFactorAuthentication = new TwoFactorAuth('Leantime');
|
||||
|
||||
return $twoFactorAuthentication->verifyCode(session('userdata.twoFASecret'), $code);
|
||||
}
|
||||
|
||||
public function get2FAVerified(): mixed
|
||||
{
|
||||
return session('userdata.twoFAVerified');
|
||||
}
|
||||
|
||||
public function set2FAVerified(): void
|
||||
{
|
||||
session(['userdata.twoFAVerified' => true]);
|
||||
}
|
||||
|
||||
private function logFailedLogin(string $user): void
|
||||
{
|
||||
$user = $user == '' ? 'unknown' : $user;
|
||||
$date = new \DateTime;
|
||||
$date = $date->format('y:m:d h:i:s');
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'];
|
||||
$msg = '['.$date.']['.$ip.'] Login failed for user: '.$user;
|
||||
|
||||
Log::info($msg);
|
||||
}
|
||||
|
||||
public function getAuthIdentifierName()
|
||||
{
|
||||
return 'id';
|
||||
}
|
||||
|
||||
public function getAuthIdentifier()
|
||||
{
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
public function getAuthPassword()
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
public function getAuthPasswordName()
|
||||
{
|
||||
return 'password';
|
||||
}
|
||||
|
||||
public function getRememberToken()
|
||||
{
|
||||
return ''; // Not implemented yet (Authenticatable::getRememberToken is contractually a string)
|
||||
}
|
||||
|
||||
public function setRememberToken($value)
|
||||
{
|
||||
// Not implemented yet
|
||||
}
|
||||
|
||||
public function getRememberTokenName()
|
||||
{
|
||||
return 'remember_token';
|
||||
}
|
||||
|
||||
public function getUserById($id)
|
||||
{
|
||||
return (object) $this->userRepo->getUser($id);
|
||||
}
|
||||
|
||||
public function validateToken(string $token): bool
|
||||
{
|
||||
$user = $this->getUserByToken($token);
|
||||
|
||||
if ($user) {
|
||||
$this->setUserSession($user);
|
||||
|
||||
// Turn off 2FA for token verification
|
||||
$this->set2FAVerified();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
public function getUserByToken(string $token): array|bool
|
||||
{
|
||||
$tokenModel = $this->tokenRepo->findToken($token);
|
||||
|
||||
if (! $tokenModel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($tokenModel['expires_at'] && strtotime($tokenModel['expires_at']) < time()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load the user associated with this token
|
||||
$user = $this->userRepo->getUser($tokenModel['tokenable_id']);
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->tokenRepo->updateLastUsedAt($tokenModel['id']);
|
||||
|
||||
return $user;
|
||||
|
||||
}
|
||||
}
|
||||
124
app/Domain/Auth/Services/AuthUser.php
Normal file
124
app/Domain/Auth/Services/AuthUser.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Services;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Auth\UserProvider;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Leantime\Domain\Auth\Models\AuthenticatableUser;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
|
||||
class AuthUser implements UserProvider
|
||||
{
|
||||
use HasApiTokens;
|
||||
|
||||
protected $authRepo;
|
||||
|
||||
protected $userRepo;
|
||||
|
||||
protected $userdata;
|
||||
|
||||
public function __construct(
|
||||
protected AuthService $authService)
|
||||
{
|
||||
$this->authRepo = $this->authService->authRepo;
|
||||
$this->userRepo = $this->authService->userRepo;
|
||||
}
|
||||
|
||||
public function retrieveById($identifier)
|
||||
{
|
||||
$userData = $this->userRepo->getUser($identifier);
|
||||
|
||||
// Not found → null, per the UserProvider contract. Returning a (non-null) empty user
|
||||
// object would let the guard treat the request as authenticated.
|
||||
if (empty($userData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthenticatableUser((array) $userData);
|
||||
}
|
||||
|
||||
public function retrieveByToken($identifier, $token)
|
||||
{
|
||||
$userData = $this->authService->getUserByToken($token);
|
||||
|
||||
if (empty($userData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthenticatableUser((array) $userData);
|
||||
}
|
||||
|
||||
public function updateRememberToken(Authenticatable $user, $token)
|
||||
{
|
||||
// Not implemented for now
|
||||
}
|
||||
|
||||
public function retrieveByCredentials(array $credentials)
|
||||
{
|
||||
if (! isset($credentials['username'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->authRepo->getUserByLogin(
|
||||
$credentials['username'],
|
||||
$credentials['password'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public function validateCredentials(Authenticatable $user, array $credentials)
|
||||
{
|
||||
return $this->authService->login(
|
||||
$credentials['username'],
|
||||
$credentials['password']
|
||||
);
|
||||
}
|
||||
|
||||
public function rehashPasswordIfRequired(Authenticatable $user, array $credentials, bool $force = false) {}
|
||||
|
||||
public function getOrCreateUser($user, $source)
|
||||
{
|
||||
// Look up the existing account in a separate variable — the $user param holds the
|
||||
// external/OAuth profile data we need to create the account from, so it must not be
|
||||
// overwritten by the lookup result (doing so previously built new users with empty fields).
|
||||
$existingUser = $this->authRepo->getUserByEmail($user['email']);
|
||||
|
||||
if (empty($existingUser) && config()->get('auth.create_user')) {
|
||||
|
||||
$userArray = [
|
||||
'firstname' => $user['firstname'],
|
||||
'lastname' => $user['lastname'],
|
||||
'phone' => $user['phone'] ?? '',
|
||||
'user' => $user['email'] ?? '',
|
||||
'role' => $user['role'] ?? '30',
|
||||
'department' => $user['department'] ?? '',
|
||||
'jobTitle' => $user['jobTitle'] ?? '',
|
||||
'jobLevel' => $user['jobLevel'] ?? '',
|
||||
'password' => '',
|
||||
'clientId' => '',
|
||||
'source' => $source,
|
||||
'status' => 'a',
|
||||
];
|
||||
|
||||
$this->userRepo->addUser($userArray);
|
||||
$existingUser = $this->authRepo->getUserByEmail($user['email']);
|
||||
}
|
||||
|
||||
return $existingUser;
|
||||
}
|
||||
|
||||
public function setUser($userId)
|
||||
{
|
||||
$this->userdata = $this->userRepo->getUser($userId);
|
||||
|
||||
$this->setUserSession($this->userdata);
|
||||
}
|
||||
|
||||
protected function setUserSession($user)
|
||||
{
|
||||
// Sanctum/Bearer-token session. twoFAVerified: true — the token is the strong credential
|
||||
// and no interactive 2FA is possible. Built via the shared factory so role (NAME string,
|
||||
// not raw int) and every other field stay identical to the web + x-api-key paths.
|
||||
session(['userdata' => UserSessionBuilder::build($user, isExternalAuth: false, twoFAVerified: true)]);
|
||||
}
|
||||
}
|
||||
368
app/Domain/Auth/Services/Onboarding.php
Normal file
368
app/Domain/Auth/Services/Onboarding.php
Normal file
@@ -0,0 +1,368 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Services;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Core\UI\Theme;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingService;
|
||||
use Leantime\Domain\Users\Services\Users as UserService;
|
||||
|
||||
/**
|
||||
* Onboarding service - encapsulates the multi-step user invite / onboarding
|
||||
* flow that was previously orchestrated inside the UserInvite controller.
|
||||
*/
|
||||
class Onboarding
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
/**
|
||||
* The event context the onboarding controller used to dispatch events under.
|
||||
*
|
||||
* Onboarding events are dispatched from this service, but the original
|
||||
* (controller-based) event names must be preserved so registered listeners
|
||||
* (including plugins) keep matching. Passing this fully-qualified context to
|
||||
* the dispatch helpers keeps the emitted event names byte-identical.
|
||||
*/
|
||||
private const EVENT_CONTEXT = 'leantime.domain.auth.controllers.userinvite.post';
|
||||
|
||||
/**
|
||||
* init - initializes the service dependencies.
|
||||
*/
|
||||
public function __construct(
|
||||
private AuthService $authService,
|
||||
private UserService $userService,
|
||||
private SettingService $settingService,
|
||||
private Theme $themeCore,
|
||||
private LanguageCore $language
|
||||
) {}
|
||||
|
||||
/**
|
||||
* getInviteSettings - builds the defaulted settings payload used to render the
|
||||
* onboarding screens for a given invited user.
|
||||
*
|
||||
* @param array $user the invited user record
|
||||
* @return array the resolved settings (theme, colorMode, colorScheme, themeFont,
|
||||
* date/time formats, timezone, workdays and daySchedule plus the
|
||||
* available option catalogs)
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getInviteSettings(array $user): array
|
||||
{
|
||||
$userId = $user['id'];
|
||||
|
||||
$userTheme = $this->settingService->getSetting('usersettings.'.$userId.'.theme');
|
||||
if (! $userTheme) {
|
||||
$userTheme = 'default';
|
||||
}
|
||||
|
||||
$userColorMode = $this->settingService->getSetting('usersettings.'.$userId.'.colorMode');
|
||||
if (! $userColorMode) {
|
||||
$userColorMode = 'light';
|
||||
}
|
||||
|
||||
$userColorScheme = $this->settingService->getSetting('usersettings.'.$userId.'.colorScheme');
|
||||
if (! $userColorScheme) {
|
||||
$userColorScheme = 'companyColors';
|
||||
}
|
||||
|
||||
$themeFont = $this->settingService->getSetting('usersettings.'.$userId.'.themeFont');
|
||||
if (! $themeFont) {
|
||||
$themeFont = 'Roboto';
|
||||
}
|
||||
|
||||
$userDateFormat = $this->settingService->getSetting('usersettings.'.$userId.'.date_format');
|
||||
$userTimeFormat = $this->settingService->getSetting('usersettings.'.$userId.'.time_format');
|
||||
|
||||
$timezone = $this->settingService->getSetting('usersettings.'.$userId.'.timezone');
|
||||
if (! $timezone) {
|
||||
$timezone = date_default_timezone_get();
|
||||
}
|
||||
|
||||
$workdays = $this->settingService->getSetting('usersettings.'.$userId.'.workdays');
|
||||
if (! $workdays) {
|
||||
$workdays = $this->getDefaultWorkdays();
|
||||
} else {
|
||||
$workdays = safe_unserialize($workdays, []);
|
||||
}
|
||||
|
||||
$daySchedule = $this->settingService->getSetting('usersettings.'.$userId.'.daySchedule');
|
||||
if ($daySchedule) {
|
||||
$daySchedule = safe_unserialize($daySchedule, []);
|
||||
} else {
|
||||
$daySchedule = $this->getDefaultDaySchedule();
|
||||
}
|
||||
|
||||
return [
|
||||
'userTheme' => $userTheme,
|
||||
'userColorMode' => $userColorMode,
|
||||
'userColorScheme' => $userColorScheme,
|
||||
'themeFont' => $themeFont,
|
||||
'dateFormat' => $userDateFormat,
|
||||
'timeFormat' => $userTimeFormat,
|
||||
'dateTimeValues' => $this->getSupportedDateTimeFormats(),
|
||||
'timezone' => $timezone,
|
||||
'timezoneOptions' => timezone_identifiers_list(),
|
||||
'availableColorSchemes' => $this->themeCore->getAvailableColorSchemes(),
|
||||
'availableFonts' => $this->themeCore->getAvailableFonts(),
|
||||
'fontTooltips' => $this->themeCore->fontTooltips,
|
||||
'availableThemes' => $this->themeCore->getAll(),
|
||||
'languageList' => $this->language->getLanguageList(),
|
||||
'workdays' => $workdays,
|
||||
'daySchedule' => $daySchedule,
|
||||
'dayHourOptions' => $this->getDayHourOptions(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* getDefaultWorkdays - returns the default weekly working hours used when a
|
||||
* user has not yet configured their schedule.
|
||||
*
|
||||
* @return array<int, array{start: string, end: string}>
|
||||
*/
|
||||
public function getDefaultWorkdays(): array
|
||||
{
|
||||
return [
|
||||
1 => ['start' => '09:00', 'end' => '17:00'],
|
||||
2 => ['start' => '09:00', 'end' => '17:00'],
|
||||
3 => ['start' => '09:00', 'end' => '17:00'],
|
||||
4 => ['start' => '09:00', 'end' => '17:00'],
|
||||
5 => ['start' => '09:00', 'end' => '17:00'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* getDefaultDaySchedule - returns the default daily schedule used when a user
|
||||
* has not yet configured one.
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function getDefaultDaySchedule(): array
|
||||
{
|
||||
return [
|
||||
'wakeup' => 6,
|
||||
'workStart' => 8,
|
||||
'lunch' => 12,
|
||||
'workEnd' => 16,
|
||||
'bed' => 22,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* getDayHourOptions - returns the catalog of selectable two-hour blocks used
|
||||
* when configuring the daily schedule.
|
||||
*
|
||||
* @return array<int, array{start: string, end: string}>
|
||||
*/
|
||||
public function getDayHourOptions(): array
|
||||
{
|
||||
return [
|
||||
0 => ['start' => '0:00', 'end' => '2:00'],
|
||||
2 => ['start' => '2:00', 'end' => '4:00'],
|
||||
4 => ['start' => '4:00', 'end' => '6:00'],
|
||||
6 => ['start' => '6:00', 'end' => '8:00'],
|
||||
8 => ['start' => '8:00', 'end' => '10:00'],
|
||||
10 => ['start' => '10:00', 'end' => '12:00'],
|
||||
12 => ['start' => '12:00', 'end' => '14:00'],
|
||||
14 => ['start' => '14:00', 'end' => '16:00'],
|
||||
16 => ['start' => '16:00', 'end' => '18:00'],
|
||||
18 => ['start' => '18:00', 'end' => '20:00'],
|
||||
20 => ['start' => '20:00', 'end' => '22:00'],
|
||||
22 => ['start' => '22:00', 'end' => '0:00'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* getSupportedDateTimeFormats - returns the catalog of supported date and time
|
||||
* format options shown during onboarding.
|
||||
*
|
||||
* @return array{dates: array<int, string>, times: array<int, string>}
|
||||
*/
|
||||
public function getSupportedDateTimeFormats(): array
|
||||
{
|
||||
return [
|
||||
'dates' => [
|
||||
$this->language->__('language.dateformat'),
|
||||
'Y-m-d',
|
||||
'D, d M y',
|
||||
'l, d-M-y',
|
||||
'd.m.Y',
|
||||
'd/m/Y',
|
||||
'd. F Y',
|
||||
'm-d-Y',
|
||||
'dmY',
|
||||
'F d, Y',
|
||||
'd F Y',
|
||||
],
|
||||
'times' => [
|
||||
$this->language->__('language.timeformat'),
|
||||
'H:i P',
|
||||
'H:i O',
|
||||
'H:i T',
|
||||
'H:i:s',
|
||||
'H:i',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* saveAccount - first onboarding step: validates the chosen password, assembles
|
||||
* the user record from the submitted profile fields and persists it.
|
||||
*
|
||||
* The plaintext password is stored in the session (tempPassword) so the user can
|
||||
* be logged in automatically once onboarding completes, mirroring the original flow.
|
||||
*
|
||||
* @param array $userInvite the invited user record (resolved from the invite link)
|
||||
* @param string $name the full name as submitted (split into first/last name)
|
||||
* @param string $jobTitle the submitted job title
|
||||
* @param string $password the chosen password
|
||||
* @return string 'weak' if the password is not strong enough, 'saved' if the user was
|
||||
* persisted, 'error' if persistence failed
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function saveAccount(array $userInvite, string $name, string $jobTitle, string $password): string
|
||||
{
|
||||
if (! $this->userService->checkPasswordStrength($password)) {
|
||||
return 'weak';
|
||||
}
|
||||
|
||||
$nameParts = explode(' ', $name);
|
||||
$userInvite['firstname'] = $nameParts[0];
|
||||
$userInvite['lastname'] = $nameParts[1] ?? '';
|
||||
$userInvite['jobTitle'] = $jobTitle;
|
||||
$userInvite['status'] = 'i';
|
||||
$userInvite['user'] = $userInvite['username'];
|
||||
$userInvite['password'] = $password;
|
||||
|
||||
session(['tempPassword' => $password]);
|
||||
|
||||
if ($this->userService->editUser($userInvite, $userInvite['id'])) {
|
||||
return 'saved';
|
||||
}
|
||||
|
||||
return 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* saveThemeChoice - second onboarding step: persists the chosen theme and font,
|
||||
* activates them and dispatches the related onboarding events.
|
||||
*
|
||||
* @param array $userInvite the invited user record
|
||||
* @param string $theme the chosen theme
|
||||
* @param string $themeFont the chosen font
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function saveThemeChoice(array $userInvite, string $theme, string $themeFont): void
|
||||
{
|
||||
$postTheme = htmlentities($theme);
|
||||
$font = htmlentities($themeFont);
|
||||
|
||||
$this->settingService->saveSetting('usersettings.'.$userInvite['id'].'.theme', $postTheme);
|
||||
$this->settingService->saveSetting('usersettings.'.$userInvite['id'].'.themeFont', $font);
|
||||
|
||||
$this->themeCore->clearCache();
|
||||
$this->themeCore->setActive($postTheme);
|
||||
$this->themeCore->setFont($font);
|
||||
$this->themeCore->clearCache();
|
||||
|
||||
self::dispatchEvent('onboarding_themechoice_'.$postTheme, [], self::EVENT_CONTEXT);
|
||||
self::dispatchEvent('onboarding_themechoice_'.$font, [], self::EVENT_CONTEXT);
|
||||
}
|
||||
|
||||
/**
|
||||
* saveColorChoice - third onboarding step: persists the chosen color mode and
|
||||
* scheme, activates them and dispatches the related onboarding events.
|
||||
*
|
||||
* @param array $userInvite the invited user record
|
||||
* @param string $colorMode the chosen color mode
|
||||
* @param string $colorScheme the chosen color scheme
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function saveColorChoice(array $userInvite, string $colorMode, string $colorScheme): void
|
||||
{
|
||||
$postColorMode = htmlentities($colorMode);
|
||||
$postColorScheme = htmlentities($colorScheme);
|
||||
|
||||
$this->settingService->saveSetting('usersettings.'.$userInvite['id'].'.colorMode', $postColorMode);
|
||||
$this->settingService->saveSetting('usersettings.'.$userInvite['id'].'.colorScheme', $postColorScheme);
|
||||
|
||||
self::dispatchEvent('onboarding_colorchoice_'.$postColorMode, [], self::EVENT_CONTEXT);
|
||||
self::dispatchEvent('onboarding_colorchoice_'.$postColorScheme, [], self::EVENT_CONTEXT);
|
||||
|
||||
$this->themeCore->clearCache();
|
||||
$this->themeCore->setColorMode($postColorMode);
|
||||
$this->themeCore->setColorScheme($postColorScheme);
|
||||
$this->themeCore->clearCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* saveSchedule - fourth onboarding step: assembles the day schedule from the
|
||||
* submitted values, dispatches the related onboarding events and persists it.
|
||||
*
|
||||
* @param array $userInvite the invited user record
|
||||
* @param string $workStart the submitted work start block
|
||||
* @param string $lunch the submitted lunch block
|
||||
* @param string $workEnd the submitted work end block
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function saveSchedule(array $userInvite, string $workStart, string $lunch, string $workEnd): void
|
||||
{
|
||||
$daySchedule = [
|
||||
'wakeup' => '',
|
||||
'workStart' => $workStart,
|
||||
'lunch' => $lunch,
|
||||
'workEnd' => $workEnd,
|
||||
'bed' => '',
|
||||
];
|
||||
|
||||
self::dispatchEvent('onboarding_schedule_start_'.$daySchedule['workStart'], [], self::EVENT_CONTEXT);
|
||||
self::dispatchEvent('onboarding_schedule_lunch_'.$daySchedule['lunch'], [], self::EVENT_CONTEXT);
|
||||
self::dispatchEvent('onboarding_schedule_end_'.$daySchedule['workEnd'], [], self::EVENT_CONTEXT);
|
||||
|
||||
$this->settingService->saveSetting('usersettings.'.$userInvite['id'].'.daySchedule', serialize($daySchedule));
|
||||
}
|
||||
|
||||
/**
|
||||
* completeOnboarding - final onboarding step: activates the user, dispatches the
|
||||
* onboarding-finished and signup-success events, then logs the user in using the
|
||||
* temporary password captured during account setup.
|
||||
*
|
||||
* @param array $userInvite the invited user record
|
||||
* @return bool true if the user was successfully logged in, false otherwise
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function completeOnboarding(array $userInvite): bool
|
||||
{
|
||||
$userInvite['status'] = 'A';
|
||||
$userInvite['password'] = '';
|
||||
$userInvite['user'] = $userInvite['username'];
|
||||
|
||||
$this->userService->editUser($userInvite, $userInvite['id']);
|
||||
|
||||
self::dispatchEvent('onboarding_finished', [], self::EVENT_CONTEXT);
|
||||
|
||||
$loggedIn = $this->authService->login($userInvite['username'], session('tempPassword'));
|
||||
|
||||
session()->forget('tempPassword');
|
||||
|
||||
self::dispatch_event('userSignUpSuccess', ['user' => $userInvite], self::EVENT_CONTEXT);
|
||||
|
||||
return $loggedIn;
|
||||
}
|
||||
}
|
||||
56
app/Domain/Auth/Services/UserSessionBuilder.php
Normal file
56
app/Domain/Auth/Services/UserSessionBuilder.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Services;
|
||||
|
||||
use Leantime\Core\Support\NameSanitizer;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
|
||||
/**
|
||||
* Single source of truth for the `session('userdata')` array.
|
||||
*
|
||||
* Every authentication path — web login ({@see Auth::setUserSession}), x-api-key
|
||||
* ({@see \Leantime\Domain\Api\Services\Api::setApiUserSession}) and Sanctum/Bearer tokens
|
||||
* ({@see AuthUser::setUserSession}) — builds this same structure. They used to each build it
|
||||
* inline, which let fields drift silently between paths:
|
||||
* - `role` was stored as the raw DB int on the Bearer path but as the role-NAME string on the
|
||||
* others; the permission engine validates against the name list, so Bearer auth denied every
|
||||
* gated method with -32001 (the 3.9.x regression).
|
||||
* - `twoFAVerified` likewise diverged between the two token paths.
|
||||
*
|
||||
* Routing all three through this factory makes those bugs structurally impossible: `role` is
|
||||
* ALWAYS converted via {@see Roles::getRoleString()}, and every field is produced identically.
|
||||
* The two genuinely per-path values — whether the session is external-auth and whether 2FA is
|
||||
* already satisfied — are explicit parameters.
|
||||
*/
|
||||
class UserSessionBuilder
|
||||
{
|
||||
/**
|
||||
* Build the canonical userdata array from a `zp_user` row.
|
||||
*
|
||||
* @param array $user A zp_user row (id, firstname, username, profileId, clientId, role, …).
|
||||
* @param bool $isExternalAuth True when the user authenticated via an external provider.
|
||||
* @param bool $twoFAVerified True when 2FA is considered satisfied (token auth — the token
|
||||
* is the strong credential and no interactive 2FA is possible).
|
||||
* @return array<string, mixed> The userdata array to store in `session('userdata')`.
|
||||
*/
|
||||
public static function build(array $user, bool $isExternalAuth = false, bool $twoFAVerified = false): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $user['id'],
|
||||
'name' => NameSanitizer::clean($user['firstname'] ?? ''),
|
||||
'profileId' => $user['profileId'] ?? '',
|
||||
'mail' => filter_var($user['username'] ?? '', FILTER_SANITIZE_EMAIL),
|
||||
'clientId' => $user['clientId'] ?? '',
|
||||
// ALWAYS the role-NAME string the permission engine validates against — never the raw
|
||||
// DB int. This is the field whose drift caused the Bearer -32001 regression.
|
||||
'role' => Roles::getRoleString($user['role']),
|
||||
'settings' => ! empty($user['settings']) ? safe_unserialize($user['settings'], []) : [],
|
||||
'twoFAEnabled' => $user['twoFAEnabled'] ?? false,
|
||||
'twoFAVerified' => $twoFAVerified,
|
||||
'twoFASecret' => $user['twoFASecret'] ?? '',
|
||||
'isExternalAuth' => $isExternalAuth,
|
||||
'createdOn' => ! empty($user['createdOn']) ? dtHelper()->parseDbDateTime($user['createdOn']) : dtHelper()->userNow(),
|
||||
'modified' => ! empty($user['modified']) ? dtHelper()->parseDbDateTime($user['modified']) : dtHelper()->userNow(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user