OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View 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']);
}
}

View 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;
}
}

View 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)]);
}
}

View 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;
}
}

View 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(),
];
}
}