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,111 @@
<?php
namespace Leantime\Domain\TwoFA\Controllers;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\TwoFA\Services\TwoFA as TwoFAService;
use RobThree\Auth\TwoFactorAuthException;
use Symfony\Component\HttpFoundation\Response;
class Edit extends Controller
{
private TwoFAService $twoFAService;
/**
* Initializes dependencies.
*/
public function init(TwoFAService $twoFAService): void
{
$this->twoFAService = $twoFAService;
}
/**
* Displays the 2FA setup/edit page.
*
* @param array $params Request parameters
*
* @throws TwoFactorAuthException
*/
public function get(array $params): Response
{
$this->assignSetupData();
$this->generateFormTokens();
return $this->tpl->display('twofa.edit');
}
/**
* Handles 2FA enable/disable actions.
*
* @param array $params Request parameters
*
* @throws TwoFactorAuthException
*/
public function post(array $params): Response
{
$userId = (int) session('userdata.id');
if (isset($_POST['disable'])) {
if ($this->isValidFormToken()) {
$this->twoFAService->disable2FA($userId);
} else {
$this->tpl->setNotification($this->language->__('notification.form_token_incorrect'), 'error');
}
}
if (isset($_POST['save'])) {
if (isset($_POST['secret'])) {
$this->twoFAService->saveSecret($userId, $_POST['secret']);
}
if (isset($_POST['secret'], $_POST['twoFACode'])) {
if ($this->twoFAService->verifyAndEnable($userId, $_POST['secret'], $_POST['twoFACode'])) {
$this->tpl->setNotification($this->language->__('notification.twoFA_enabled_success'), 'success', 'twoFAenabled');
} else {
$this->tpl->setNotification($this->language->__('notification.incorrect_twoFA_code'), 'error');
}
}
}
$this->assignSetupData();
$this->generateFormTokens();
return $this->tpl->display('twofa.edit');
}
/**
* Assigns the current 2FA setup state (secret, QR data, enabled flag) to the template.
*
* @throws TwoFactorAuthException
*/
private function assignSetupData(): void
{
$setup = $this->twoFAService->getSetupData((int) session('userdata.id'));
$this->tpl->assign('secret', $setup['secret']);
$this->tpl->assign('twoFAEnabled', $setup['twoFAEnabled']);
if (! $setup['twoFAEnabled']) {
$this->tpl->assign('qrData', $setup['qrData']);
}
}
/**
* Validates the submitted CSRF form token against the session.
*/
private function isValidFormToken(): bool
{
return isset($_POST[session('formTokenName')])
&& $_POST[session('formTokenName')] == session('formTokenValue');
}
/**
* Generates CSRF form tokens for sensitive forms.
*/
private function generateFormTokens(): void
{
$permittedChars = '0123456789abcdefghijklmnopqrstuvwxyz';
session(['formTokenName' => substr(str_shuffle($permittedChars), 0, 32)]);
session(['formTokenValue' => substr(str_shuffle($permittedChars), 0, 32)]);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Leantime\Domain\TwoFA\Controllers;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller as FrontcontrollerCore;
use Leantime\Domain\Auth\Services\Auth as AuthService;
use Symfony\Component\HttpFoundation\Response;
class Verify extends Controller
{
private AuthService $authService;
/**
* init - initialize private variables
*
* @params parameters or body of the request
*/
public function init(AuthService $authService)
{
$this->authService = $authService;
}
/**
* get - handle get requests
*
* @params parameters or body of the request
*/
public function get($params)
{
$redirectUrl = BASE_URL.'/dashboard/home';
if (isset($_GET['redirect'])) {
$redirectUrl = BASE_URL.urldecode($_GET['redirect']);
}
$this->tpl->assign('redirectUrl', $redirectUrl);
return $this->tpl->display('twofa.verify', 'entry');
}
public function post($params): Response
{
if (session()->exists('userdata') && $this->authService->use2FA()) {
if (isset($params['twoFA_code']) === true) {
$redirectUrl = filter_var($params['redirectUrl'], FILTER_SANITIZE_URL);
if ($this->authService->verify2FA($params['twoFA_code'])) {
$this->authService->set2FAVerified();
return FrontcontrollerCore::redirect($redirectUrl);
} else {
$this->tpl->setNotification('notification.incorrect_twoFA_code', 'error');
return FrontcontrollerCore::redirect(BASE_URL.'/twoFA/verify');
}
} else {
$this->tpl->setNotification('notification.incorrect_twoFA_code', 'error');
return FrontcontrollerCore::redirect(BASE_URL.'/twoFA/verify');
}
}
/** @todo make a 400 response page **/
return $this->tpl->display('error.400');
}
}

View File

@@ -0,0 +1,148 @@
<?php
namespace Leantime\Domain\TwoFA\Services;
use Endroid\QrCode\Color\Color;
use Endroid\QrCode\Label\Label;
use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\PngWriter;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use RobThree\Auth\Providers\Qr\IQRCodeProvider;
use RobThree\Auth\TwoFactorAuth;
use RobThree\Auth\TwoFactorAuthException;
/**
* Two-factor authentication domain service.
*
* Owns TOTP secret generation, QR-code construction, code verification, and
* persistence of the user's 2FA state so controllers stay thin.
*
* @api
*/
class TwoFA
{
public function __construct(
private UserRepository $userRepo
) {}
/**
* Builds the data needed to render the 2FA setup page for a user.
*
* Generates a fresh TOTP secret (and matching QR code) when the user does
* not already have one. The QR code is omitted when 2FA is already enabled.
*
* @param int $userId The user to build setup data for
* @return array{secret: string, qrData: string|null, twoFAEnabled: bool}
*
* @throws TwoFactorAuthException
*
* @api
*/
public function getSetupData(int $userId): array
{
$user = $this->userRepo->getUser($userId);
$tfa = $this->createTwoFactorAuth();
$secret = $user['twoFASecret'] ?? '';
if (empty($secret)) {
$secret = $tfa->createSecret(160);
}
$enabled = (bool) ($user['twoFAEnabled'] ?? false);
return [
'secret' => $secret,
'qrData' => $enabled ? null : $tfa->getQRCodeImageAsDataUri($user['username'], $secret),
'twoFAEnabled' => $enabled,
];
}
/**
* Persists a TOTP secret for the user without enabling 2FA.
*
* Stored ahead of verification so a failed code entry does not discard the
* secret the user is mid-way through enrolling.
*
* @param int $userId The user to store the secret for
* @param string $secret The TOTP secret
*
* @api
*/
public function saveSecret(int $userId, string $secret): void
{
$this->userRepo->patchUser($userId, ['twoFASecret' => $secret]);
}
/**
* Verifies a submitted TOTP code against the secret and, when valid, enables 2FA.
*
* @param int $userId The user enabling 2FA
* @param string $secret The TOTP secret
* @param string $code The code submitted by the user
* @return bool True when the code verified and 2FA was enabled
*
* @throws TwoFactorAuthException
*
* @api
*/
public function verifyAndEnable(int $userId, string $secret, string $code): bool
{
$verified = $this->createTwoFactorAuth()->verifyCode($secret, $code);
if (! $verified) {
return false;
}
$this->userRepo->patchUser($userId, [
'twoFAEnabled' => 1,
'twoFASecret' => $secret,
]);
return true;
}
/**
* Disables 2FA for the user and clears their stored secret.
*
* @param int $userId The user to disable 2FA for
*
* @api
*/
public function disable2FA(int $userId): void
{
$this->userRepo->patchUser($userId, [
'twoFAEnabled' => 0,
'twoFASecret' => null,
]);
}
/**
* Creates a TwoFactorAuth instance backed by a PNG QR-code provider.
*
* @throws TwoFactorAuthException
*/
private function createTwoFactorAuth(): TwoFactorAuth
{
return new TwoFactorAuth('Leantime', 6, 30, 'sha1', new class implements IQRCodeProvider
{
public function getMimeType(): string
{
return 'image/png';
}
public function getQRCodeImage($qrtext, $size): string
{
$writer = new PngWriter;
$qrCode = new QrCode(data: $qrtext, size: $size, backgroundColor: new Color(255, 255, 255, 127));
$label = new Label(
text: 'Label',
textColor: new Color(255, 0, 0)
);
return $writer->write($qrCode, null, null)->getString();
}
});
}
}

View File

@@ -0,0 +1,59 @@
@extends($layout)
@section('content')
<div class="pageheader">
<div class="pageicon"><span class="fa fa-lock"></span></div>
<div class="pagetitle">
<h1>{!! __('label.twoFA') !!}</h1>
</div>
</div>
<div class="maincontent">
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<div class="row-fluid">
<div class="span12">
<h3>{!! __('label.twoFA_setup') !!}</h3>
<br />
<div class="center">
@if (! $twoFAEnabled)
<h5>1. {!! __('text.twoFA_qr') !!}</h5>
<br />
<img src="{{ $qrData }}" style="border-radius: var(--box-radius);"/><br />
Secret: <p>{{ $secret }}</p><br/>
<form action="" method="post" class='stdform'>
<h5>2. {!! __('text.twoFA_verify_code') !!}</h5>
<p>
<span>{!! __('label.twoFACode_short') !!}:</span>
<x-global::forms.text-input name="twoFACode" id="twoFACode" /><br/>
</p>
<input type="hidden" name="secret" value="{{ $secret }}" />
<br/>
<p class='stdformbutton'>
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="save" id="save" />
</p>
</form>
@else
<form action="" method="post" class='stdform'>
<h5>{!! __('text.twoFA_already_enabled') !!}</h5>
<input type="hidden" name="{{ session('formTokenName') }}" value="{{ session('formTokenValue') }}" />
<p class='stdformbutton'>
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.remove')" name="disable" id="disable" />
<x-global::forms.button tag="a" link="{{ BASE_URL }}/users/editOwn">{!! __('buttons.back') !!}</x-global::forms.button>
</p>
</form>
@endif
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,31 @@
@extends($layout)
@section('content')
<div class="pageheader">
<div class="pagetitle">
<h1>{!! __('headlines.twoFA_login') !!}</h1>
</div>
</div>
<div class="regcontent">
<form id="login" action="{{ BASE_URL }}/twoFA/verify" method="post">
<input type="hidden" name="redirectUrl" value="{{ $redirectUrl }}"/>
{!! $tpl->displayInlineNotification() !!}
<div class="">
<x-global::forms.text-input name="twoFA_code" id="twoFA_code"
placeholder="{{ __('label.twoFACode') }}"
value="" autofocus />
</div>
<div class="">
<div class="forgotPwContainer">
<a href="{{ BASE_URL }}/auth/logout" class="forgotPw">{!! __('menu.sign_out') !!}</a>
</div>
<x-global::forms.button tag="input" inputType="submit" name="login" :labelText="__('buttons.login')"
contentRole="primary"/>
</div>
</form>
</div>
@endsection