Login: Added OAuth support

This commit is contained in:
Igor Scheller 2020-11-15 18:47:30 +01:00 committed by msquare
parent c57bb9395e
commit 80941c2999
29 changed files with 1410 additions and 30 deletions

View File

@ -33,6 +33,7 @@
"illuminate/container": "^7.6",
"illuminate/database": "^7.6",
"illuminate/support": "^7.6",
"league/oauth2-client": "^2.6",
"nikic/fast-route": "^1.3",
"nyholm/psr7": "^1.1",
"psr/container": "^1.0",

View File

@ -63,6 +63,41 @@ return [
'sendmail' => env('MAIL_SENDMAIL', '/usr/sbin/sendmail -bs'),
],
'oauth' => [
// '[name]' => [config]
/*
'name' => [
// Auth client ID
'client_id' => 'engelsystem',
// Auth client secret
'client_secret' => '[generated by provider]',
// Authentication URL
'url_auth' => '[generated by provider]',
// Token URL
'url_token' => '[generated by provider]',
// User info URL which provides userdata
'url_info' => '[generated by provider]',
// Info unique user id field
'id' => 'uuid',
// The following fields are used for registration
// Info username field (optional)
'username' => 'nickname',
// Info email field (optional)
'email' => 'email',
// Info first name field (optional)
'first_name' => 'first-name',
// Info last name field (optional)
'last_name' => 'last-name',
// User URL to provider, shown on provider settings page (optional)
'url' => '[provider page]',
// Only show after clicking the page title (optional)
'hidden' => false,
// Mark user as arrived when using this provider (optional)
'mark_arrived' => false,
],
*/
],
// Default theme, 1=style1.css
'theme' => env('THEME', 1),
@ -222,5 +257,5 @@ return [
'Contribution' => 'Please visit [engelsystem/engelsystem](https://github.com/engelsystem/engelsystem) if '
. 'you want to to contribute, have found any [bugs](https://github.com/engelsystem/engelsystem/issues) '
. 'or need help.'
]
],
];

View File

@ -14,6 +14,14 @@ $route->get('/login', 'AuthController@login');
$route->post('/login', 'AuthController@postLogin');
$route->get('/logout', 'AuthController@logout');
// OAuth
$route->get('/oauth/{provider:\w+}', 'OAuthController@index');
$route->post('/oauth/{provider:\w+}/connect', 'OAuthController@connect');
$route->post('/oauth/{provider:\w+}/disconnect', 'OAuthController@disconnect');
// User settings
$route->get('/settings/oauth', 'SettingsController@oauth');
// Password recovery
$route->get('/password/reset', 'PasswordResetController@reset');
$route->post('/password/reset', 'PasswordResetController@postReset');

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Migrations;
use Engelsystem\Database\Migration\Migration;
use Illuminate\Database\Schema\Blueprint;
class CreateOauthTable extends Migration
{
use Reference;
/**
* Run the migration
*/
public function up(): void
{
$this->schema->create('oauth', function (Blueprint $table) {
$table->increments('id');
$this->referencesUser($table);
$table->string('provider');
$table->string('identifier');
$table->unique(['provider', 'identifier']);
$table->timestamps();
});
}
/**
* Reverse the migration
*/
public function down(): void
{
$this->schema->drop('oauth');
}
}

View File

@ -2,6 +2,7 @@
use Carbon\Carbon;
use Engelsystem\Database\DB;
use Engelsystem\Models\OAuth;
use Engelsystem\Models\User\Contact;
use Engelsystem\Models\User\PersonalData;
use Engelsystem\Models\User\Settings;
@ -224,6 +225,19 @@ function guest_register()
->associate($user)
->save();
if ($session->has('oauth2_connect_provider') && $session->has('oauth2_user_id')) {
$oauth = new OAuth([
'provider' => $session->get('oauth2_connect_provider'),
'identifier' => $session->get('oauth2_user_id'),
]);
$oauth->user()
->associate($user)
->save();
$session->remove('oauth2_connect_provider');
$session->remove('oauth2_user_id');
}
// Assign user-group and set password
DB::insert('INSERT INTO `UserGroups` (`uid`, `group_id`) VALUES (?, -20)', [$user->id]);
auth()->setPassword($user, $request->postData('password'));
@ -254,6 +268,13 @@ function guest_register()
info((new Parsedown())->text($message));
}
// Login the user
if ($user->oauth->count()) {
/** @var OAuth $provider */
$provider = $user->oauth->first();
throw_redirect(url('/oauth/' . $provider->provider));
}
throw_redirect(page_link_to('/'));
}
}
@ -270,6 +291,24 @@ function guest_register()
$teardown_end_date = $teardown->getTimestamp();
}
$form_data = $session->get('form_data');
$session->remove('form_data');
if (!$nick && !empty($form_data['name'])) {
$nick = $form_data['name'];
}
if (!$email && !empty($form_data['email'])) {
$email = $form_data['email'];
}
if (!$preName && !empty($form_data['first_name'])) {
$preName = $form_data['first_name'];
}
if (!$lastName && !empty($form_data['last_name'])) {
$lastName = $form_data['last_name'];
}
return page_with_title(register_title(), [
__('By completing this form you\'re registering as a Chaos-Angel. This script will create you an account in the angel task scheduler.'),
$msg,

View File

@ -198,6 +198,7 @@ function user_settings()
$enable_tshirt_size = config('enable_tshirt_size');
$tshirt_sizes = config('tshirt_sizes');
$locales = config('locales');
$oauth2_providers = config('oauth');
$buildup_start_date = null;
$teardown_end_date = null;
@ -230,6 +231,7 @@ function user_settings()
$buildup_start_date,
$teardown_end_date,
$enable_tshirt_size,
$tshirt_sizes
$tshirt_sizes,
$oauth2_providers
);
}

View File

@ -16,6 +16,8 @@ use Illuminate\Support\Collection;
* @param int $teardown_end_date Unix timestamp
* @param bool $enable_tshirt_size
* @param array $tshirt_sizes
* @param array $oauth2_providers
*
* @return string
*/
function User_settings_view(
@ -25,7 +27,8 @@ function User_settings_view(
$buildup_start_date,
$teardown_end_date,
$enable_tshirt_size,
$tshirt_sizes
$tshirt_sizes,
$oauth2_providers
) {
$personalData = $user_source->personalData;
$enable_user_name = config('enable_user_name');
@ -33,6 +36,11 @@ function User_settings_view(
$enable_dect = config('enable_dect');
$enable_planned_arrival = config('enable_planned_arrival');
$showOauth = false;
foreach ($oauth2_providers as $name => $config) {
$showOauth = $showOauth || !isset($config['hidden']) || !$config['hidden'];
}
return page_with_title(settings_title(), [
msg(),
div('row', [
@ -93,6 +101,11 @@ function User_settings_view(
])
]),
div('col-md-6', [
($showOauth ?
form_info(__('oauth.login'))
. button(url('/settings/oauth'), __('settings.oauth'), 'btn-primary')
: ''
),
form([
form_info(__('Here you can change your password.')),
form_password('password', __('Old password:')),
@ -109,7 +122,7 @@ function User_settings_view(
form_info(__('Here you can choose your language:')),
form_select('language', __('Language:'), $locales, $user_source->settings->language),
form_submit('submit_language', __('Save'))
])
]),
])
])
]);

View File

@ -181,6 +181,18 @@ $(function () {
$('select').select2();
});
/**
* Show oauth buttons on welcome title click
*/
$(function () {
$('#welcome-title').on('click', function () {
$('.form-group.btn-group .btn.hidden').removeClass('hidden');
});
$('#oauth-settings-title').on('click', function () {
$('table tr.hidden').removeClass('hidden');
});
});
/**
* Set the filter selects to latest state
*

View File

@ -258,6 +258,10 @@ h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
margin-bottom: @form-group-margin-bottom;
}
.user-settings .settings-menu ul {
margin-top: @line-height-computed;
}
@keyframes pulse {
0% {
transform: rotate(0deg);

View File

@ -81,3 +81,24 @@ msgid "news.edit.contains-html"
msgstr ""
"Diese Nachricht beinhaltet HTML. Wenn du sie speicherst gehen diese "
"Formatierungen verloren!"
msgid "oauth.invalid-state"
msgstr "Ungültiger OAuth state"
msgid "oauth.provider-error"
msgstr "OAuth provider fehler"
msgid "oauth.already-connected"
msgstr "Dieser Account wurde bereits mit einem anderen Account verbunden!"
msgid "oauth.connected"
msgstr "Login provider verbunden!"
msgid "oauth.disconnected"
msgstr "Login provider getrennt!"
msgid "oauth.not-found"
msgstr "Account nicht gefunden"
msgid "oauth.provider-not-found"
msgstr "OAuth provider nicht gefunden"

View File

@ -2892,3 +2892,22 @@ msgstr "Level"
msgid "log.message"
msgstr "Nachricht"
msgid "settings.settings"
msgstr "Einstellungen"
msgid "settings.oauth"
msgstr "OAuth Einstellungen"
msgid "oauth.login"
msgstr "Login mit OAuth"
msgid "oauth.login-using-provider"
msgstr "Login mit %s"
msgid "form.connect"
msgstr "Verbinden"
msgid "form.disconnect"
msgstr "Trennen"

View File

@ -77,3 +77,24 @@ msgstr "News successfully deleted."
msgid "news.edit.contains-html"
msgstr "This message contains HTML. After saving the post some formatting will be lost!"
msgid "oauth.invalid-state"
msgstr "Invalid OAuth state"
msgid "oauth.provider-error"
msgstr "OAuth provider error"
msgid "oauth.already-connected"
msgstr "This account is already connected to another account!"
msgid "oauth.connected"
msgstr "Connected login provider!"
msgid "oauth.disconnected"
msgstr "Disconnected login provider!"
msgid "oauth.not-found"
msgstr "Unable to find account"
msgid "oauth.provider-not-found"
msgstr "Unable to find OAuth provider"

View File

@ -159,3 +159,21 @@ msgstr "Level"
msgid "log.message"
msgstr "Message"
msgid "settings.settings"
msgstr "Settings"
msgid "settings.oauth"
msgstr "OAuth Settings"
msgid "oauth.login"
msgstr "Login using OAuth"
msgid "oauth.login-using-provider"
msgstr "Login using %s"
msgid "form.connect"
msgstr "Connect"
msgid "form.disconnect"
msgstr "Disconnect"

View File

@ -11,7 +11,7 @@
</h2>
{% block content_text %}
{{ content }}
{{ __(content) }}
{% endblock %}
</div>
</div>

View File

@ -12,7 +12,7 @@
{% endblock %}
{% block content_text %}
{{ content }}
{{ __(content) }}
{% endblock %}
</div>

View File

@ -6,7 +6,7 @@
{% block content %}
<div class="col-md-12">
<div class="row">
<div class="col-sm-12 text-center">
<div class="col-sm-12 text-center" id="welcome-title">
<h2>{{ __('Welcome to the %s!', [config('name') ~ m.angel() ~ (config('app_name')|upper) ])|raw }}</h2>
</div>
</div>
@ -66,6 +66,14 @@
</button>
</div>
<div class="form-group btn-group btn-group-justified">
{% for type,config in config('oauth') %}
<a href="{{ url('oauth/' ~ type) }}" class="btn btn-primary btn-lg{% if config.hidden|default(false) %} hidden{% endif %}">
{{ __('oauth.login-using-provider', [type|capitalize]) }}
</a>
{% endfor %}
</div>
<div class="text-center">
<a href="{{ url('/password/reset') }}" class="">
{{ __('I forgot my password') }}

View File

@ -0,0 +1,41 @@
{% extends 'pages/settings/settings.twig' %}
{% import 'macros/form.twig' as f %}
{% block title %}{{ __('settings.oauth') }}{% endblock %}
{% block container_title %}
<h1 id="oauth-settings-title">{{ block('title') }}</h1>
{% endblock %}
{% block row_content %}
<table class="table table-striped">
<tbody>
{% for name,config in providers %}
<tr{% if config.hidden|default(false) %} class="hidden"{% endif %}>
<th>
{% if config.url|default %}
<a href="{{ config.url }}" target="_blank" rel="noopener">{{ name|capitalize }}</a>
{% else %}
{{ name|capitalize }}
{% endif %}
</th>
<td>
{% if not user.oauth.contains('provider', name) %}
<form method="POST" action="{{ url('/oauth/' ~ name ~ '/connect') }}">
{{ csrf() }}
{{ f.submit(__('form.connect')) }}
</form>
{% else %}
<form method="POST" action="{{ url('/oauth/' ~ name ~ '/disconnect') }}">
{{ csrf() }}
{{ f.submit(__('form.disconnect'), {'btn_type': 'danger'}) }}
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@ -0,0 +1,35 @@
{% extends 'layouts/app.twig' %}
{% block title %}{{ __('settings') }}{% endblock %}
{% block content %}
<div class="container user-settings">
<div class="row">
<div class="col-md-2 settings-menu">
<ul class="nav nav-pills nav-stacked">
{% for url,title in {
(url('/user-settings')): __('settings.settings'),
(url('/settings/oauth')): __('settings.oauth'),
} %}
<li{% if url == request.url() %} class="active"{% endif %}>
<a href="{{ url }}">{{ title }}</a>
</li>
{% endfor %}
</ul>
</div>
<div class="col-md-10">
{% block container_title %}
<h1>{{ block('title') }}</h1>
{% endblock %}
{% include 'layouts/parts/messages.twig' %}
<div class="row">
{% block row_content %}
{% endblock %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -97,6 +97,16 @@ class AuthController extends BaseController
return $this->showLogin();
}
return $this->loginUser($user);
}
/**
* @param User $user
*
* @return Response
*/
public function loginUser(User $user): Response
{
$this->session->invalidate();
$this->session->set('user_id', $user->id);
$this->session->set('locale', $user->settings->language);

View File

@ -0,0 +1,314 @@
<?php
namespace Engelsystem\Controllers;
use Carbon\Carbon;
use Engelsystem\Config\Config;
use Engelsystem\Helpers\Authenticator;
use Engelsystem\Http\Exceptions\HttpNotFound;
use Engelsystem\Http\Redirector;
use Engelsystem\Http\Request;
use Engelsystem\Http\Response;
use Engelsystem\Http\UrlGenerator;
use Engelsystem\Models\OAuth;
use Illuminate\Support\Collection;
use League\OAuth2\Client\Provider\AbstractProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Provider\GenericProvider;
use League\OAuth2\Client\Provider\ResourceOwnerInterface as ResourceOwner;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Session\Session as Session;
class OAuthController extends BaseController
{
use HasUserNotifications;
/** @var Authenticator */
protected $auth;
/** @var AuthController */
protected $authController;
/** @var Config */
protected $config;
/** @var LoggerInterface */
protected $log;
/** @var OAuth */
protected $oauth;
/** @var Redirector */
protected $redirector;
/** @var Session */
protected $session;
/** @var UrlGenerator */
protected $url;
/**
* @param Authenticator $auth
* @param AuthController $authController
* @param Config $config
* @param LoggerInterface $log
* @param OAuth $oauth
* @param Redirector $redirector
* @param Session $session
* @param UrlGenerator $url
*/
public function __construct(
Authenticator $auth,
AuthController $authController,
Config $config,
LoggerInterface $log,
OAuth $oauth,
Redirector $redirector,
Session $session,
UrlGenerator $url
) {
$this->auth = $auth;
$this->authController = $authController;
$this->config = $config;
$this->log = $log;
$this->redirector = $redirector;
$this->oauth = $oauth;
$this->session = $session;
$this->url = $url;
}
/**
* @param Request $request
*
* @return Response
*/
public function index(Request $request): Response
{
$providerName = $request->getAttribute('provider');
$provider = $this->getProvider($providerName);
if (!$request->has('code')) {
$authorizationUrl = $provider->getAuthorizationUrl();
$this->session->set('oauth2_state', $provider->getState());
return $this->redirector->to($authorizationUrl);
}
if (
!$this->session->get('oauth2_state')
|| $request->get('state') !== $this->session->get('oauth2_state')
) {
$this->session->remove('oauth2_state');
$this->log->warning('Invalid OAuth state');
throw new HttpNotFound('oauth.invalid-state');
}
try {
$accessToken = $provider->getAccessToken(
'authorization_code',
[
'code' => $request->get('code')
]
);
} catch (IdentityProviderException $e) {
$this->log->error(
'{provider} identity provider error: {error} {description}',
[
'provider' => $providerName,
'error' => $e->getMessage(),
'description' => $e->getResponseBody()['error_description'] ?: '',
]
);
throw new HttpNotFound('oauth.provider-error');
}
$resourceOwner = $provider->getResourceOwner($accessToken);
/** @var OAuth|null $oauth */
$oauth = $this->oauth
->query()
->where('provider', $providerName)
->where('identifier', $resourceOwner->getId())
->first();
$user = $this->auth->user();
if ($oauth && $user && $user->id != $oauth->user_id) {
throw new HttpNotFound('oauth.already-connected');
}
$connectProvider = $this->session->get('oauth2_connect_provider');
$this->session->remove('oauth2_connect_provider');
if (!$oauth && $user && $connectProvider && $connectProvider == $providerName) {
$oauth = new OAuth(['provider' => $providerName, 'identifier' => $resourceOwner->getId()]);
$oauth->user()
->associate($user)
->save();
$this->log->info(
'Connected OAuth user {user} using {provider}',
['provider' => $providerName, 'user' => $resourceOwner->getId()]
);
$this->addNotification('oauth.connected');
}
$config = ($this->config->get('oauth')[$providerName]);
$userdata = new Collection($resourceOwner->toArray());
if (!$oauth) {
return $this->redirectRegisterOrThrowNotFound($providerName, $resourceOwner->getId(), $config, $userdata);
}
if (isset($config['mark_arrived']) && $config['mark_arrived']) {
$this->handleArrive($providerName, $oauth, $resourceOwner);
}
return $this->authController->loginUser($oauth->user);
}
/**
* @param Request $request
*
* @return Response
*/
public function connect(Request $request): Response
{
$provider = $request->getAttribute('provider');
$this->requireProvider($provider);
$this->session->set('oauth2_connect_provider', $provider);
return $this->index($request);
}
/**
* @param Request $request
*
* @return Response
*/
public function disconnect(Request $request): Response
{
$provider = $request->getAttribute('provider');
$this->oauth
->whereUserId($this->auth->user()->id)
->where('provider', $provider)
->delete();
$this->log->info('Disconnected OAuth from {provider}', ['provider' => $provider]);
$this->addNotification('oauth.disconnected');
return $this->redirector->back();
}
/**
* @param string $name
*
* @return AbstractProvider
*/
protected function getProvider(string $name): AbstractProvider
{
$this->requireProvider($name);
$config = $this->config->get('oauth')[$name];
return new GenericProvider(
[
'clientId' => $config['client_id'],
'clientSecret' => $config['client_secret'],
'redirectUri' => $this->url->to('oauth/' . $name),
'urlAuthorize' => $config['url_auth'],
'urlAccessToken' => $config['url_token'],
'urlResourceOwnerDetails' => $config['url_info'],
'responseResourceOwnerId' => $config['id'],
]
);
}
/**
* @param string $provider
*/
protected function requireProvider(string $provider): void
{
if (!$this->isValidProvider($provider)) {
throw new HttpNotFound('oauth.provider-not-found');
}
}
/**
* @param string $name
*
* @return bool
*/
protected function isValidProvider(string $name): bool
{
$config = $this->config->get('oauth');
return isset($config[$name]);
}
/**
* @param OAuth $auth
* @param string $providerName
* @param ResourceOwner $resourceOwner
*/
protected function handleArrive(
string $providerName,
OAuth $auth,
ResourceOwner $resourceOwner
): void {
$user = $auth->user;
$userState = $user->state;
if ($userState->arrived) {
return;
}
$userState->arrived = true;
$userState->arrival_date = new Carbon();
$userState->save();
$this->log->info(
'Set user {name} ({id}) as arrived via {provider} user {user}',
[
'provider' => $providerName,
'user' => $resourceOwner->getId(),
'name' => $user->name,
'id' => $user->id
]
);
}
/**
* @param string $providerName
* @param string $providerUserIdentifier
* @param array $config
* @param Collection $userdata
*
* @return Response
*/
protected function redirectRegisterOrThrowNotFound(
string $providerName,
string $providerUserIdentifier,
array $config,
Collection $userdata
): Response {
if (!$this->config->get('registration_enabled')) {
throw new HttpNotFound('oauth.not-found');
}
$this->session->set(
'form_data',
[
'name' => $userdata->get($config['username']),
'email' => $userdata->get($config['email']),
'first_name' => $userdata->get($config['first_name']),
'last_name' => $userdata->get($config['last_name']),
],
);
$this->session->set('oauth2_connect_provider', $providerName);
$this->session->set('oauth2_user_id', $providerUserIdentifier);
return $this->redirector->to('/register');
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace Engelsystem\Controllers;
use Engelsystem\Config\Config;
use Engelsystem\Http\Exceptions\HttpNotFound;
use Engelsystem\Http\Response;
class SettingsController extends BaseController
{
use HasUserNotifications;
/** @var Config */
protected $config;
/** @var Response */
protected $response;
/**
* @param Config $config
* @param Response $response
*/
public function __construct(
Config $config,
Response $response
) {
$this->config = $config;
$this->response = $response;
}
/**
* @return Response
*/
public function oauth(): Response
{
$providers = $this->config->get('oauth');
if (empty($providers)) {
throw new HttpNotFound();
}
return $this->response->withView(
'pages/settings/oauth.twig',
[
'providers' => $providers,
] + $this->getNotifications(),
);
}
}

37
src/Models/OAuth.php Normal file
View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Models;
use Carbon\Carbon;
use Engelsystem\Models\User\UsesUserModel;
use Illuminate\Database\Query\Builder as QueryBuilder;
/**
* @property int $id
* @property string $provider
* @property string $identifier
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*
* @method static QueryBuilder|OAuth[] whereId($value)
* @method static QueryBuilder|OAuth[] whereProvider($value)
* @method static QueryBuilder|OAuth[] whereIdentifier($value)
*/
class OAuth extends BaseModel
{
use UsesUserModel;
/** @var string */
public $table = 'oauth';
/** @var bool Enable timestamps */
public $timestamps = true;
/** @var array */
protected $fillable = [
'provider',
'identifier',
];
}

View File

@ -7,6 +7,7 @@ use Engelsystem\Models\BaseModel;
use Engelsystem\Models\Message;
use Engelsystem\Models\News;
use Engelsystem\Models\NewsComment;
use Engelsystem\Models\OAuth;
use Engelsystem\Models\Question;
use Engelsystem\Models\Worklog;
use Illuminate\Database\Eloquent\Collection;
@ -30,10 +31,12 @@ use Illuminate\Database\Query\Builder as QueryBuilder;
* @property-read QueryBuilder|State $state
* @property-read Collection|News[] $news
* @property-read Collection|NewsComment[] $newsComments
* @property-read Collection|OAuth[] $oauth
* @property-read Collection|Worklog[] $worklogs
* @property-read Collection|Worklog[] $worklogsCreated
* @property-read int|null $news_count
* @property-read int|null $news_comments_count
* @property-read int|null $oauth_count
* @property-read int|null $worklogs_count
* @property-read int|null $worklogs_created_count
*
@ -133,6 +136,14 @@ class User extends BaseModel
return $this->hasMany(NewsComment::class);
}
/**
* @return HasMany
*/
public function oauth(): HasMany
{
return $this->hasMany(OAuth::class);
}
/**
* @return HasMany
*/

View File

@ -3,6 +3,7 @@
namespace Engelsystem\Renderer\Twig\Extensions;
use Engelsystem\Helpers\Authenticator;
use Engelsystem\Http\Request;
use Twig\Extension\AbstractExtension as TwigExtension;
use Twig\Extension\GlobalsInterface as GlobalsInterface;
@ -11,12 +12,17 @@ class Globals extends TwigExtension implements GlobalsInterface
/** @var Authenticator */
protected $auth;
/** @var Request */
protected $request;
/**
* @param Authenticator $auth
* @param Request $request
*/
public function __construct(Authenticator $auth)
public function __construct(Authenticator $auth, Request $request)
{
$this->auth = $auth;
$this->request = $request;
}
/**
@ -30,6 +36,7 @@ class Globals extends TwigExtension implements GlobalsInterface
return [
'user' => $user ? $user : [],
'request' => $this->request,
];
}
}

View File

@ -73,21 +73,7 @@ class AuthControllerTest extends TestCase
$validator = new Validator();
$session->set('errors', [['bar' => 'some.bar.error']]);
$this->app->instance('session', $session);
$user = new User([
'name' => 'foo',
'password' => '',
'email' => '',
'api_key' => '',
'last_login_at' => null,
]);
$user->forceFill(['id' => 42]);
$user->save();
$settings = new Settings(['language' => 'de_DE', 'theme' => '']);
$settings->user()
->associate($user)
->save();
$user = $this->createUser();
$auth->expects($this->exactly(2))
->method('authenticate')
@ -101,14 +87,20 @@ class AuthControllerTest extends TestCase
$this->assertArraySubset(['errors' => collect(['some.bar.error', 'auth.not-found'])], $data);
return $response;
});
$redirect->expects($this->once())
->method('to')
->with('news')
/** @var AuthController|MockObject $controller */
$controller = $this->getMockBuilder(AuthController::class)
->setConstructorArgs([$response, $session, $redirect, $config, $auth])
->onlyMethods(['loginUser'])
->getMock();
$controller->setValidator($validator);
$controller->expects($this->once())
->method('loginUser')
->with($user)
->willReturn($response);
// No credentials
$controller = new AuthController($response, $session, $redirect, $config, $auth);
$controller->setValidator($validator);
try {
$controller->postLogin($request);
$this->fail('Login without credentials possible');
@ -130,7 +122,34 @@ class AuthControllerTest extends TestCase
// Authenticated user
$controller->postLogin($request);
}
/**
* @covers \Engelsystem\Controllers\AuthController::loginUser
*/
public function testLoginUser()
{
$this->initDatabase();
/** @var Response|MockObject $response */
$response = $this->createMock(Response::class);
/** @var Redirector|MockObject $redirect */
/** @var Config $config */
/** @var Authenticator|MockObject $auth */
list(, , $redirect, $config, $auth) = $this->getMocks();
$session = new Session(new MockArraySessionStorage());
$session->set('foo', 'bar');
$user = $this->createUser();
$redirect->expects($this->once())
->method('to')
->with('news')
->willReturn($response);
$controller = new AuthController($response, $session, $redirect, $config, $auth);
$controller->loginUser($user);
$this->assertFalse($session->has('foo'));
$this->assertNotNull($user->last_login_at);
$this->assertEquals(['user_id' => 42, 'locale' => 'de_DE'], $session->all());
}
@ -161,6 +180,29 @@ class AuthControllerTest extends TestCase
$this->assertEquals($response, $return);
}
/**
* @return User
*/
protected function createUser(): User
{
$user = new User([
'name' => 'foo',
'password' => '',
'email' => '',
'api_key' => '',
'last_login_at' => null,
]);
$user->forceFill(['id' => 42]);
$user->save();
$settings = new Settings(['language' => 'de_DE', 'theme' => '']);
$settings->user()
->associate($user)
->save();
return $user;
}
/**
* @return array
*/

View File

@ -0,0 +1,517 @@
<?php
namespace Engelsystem\Test\Unit\Controllers;
use Engelsystem\Config\Config;
use Engelsystem\Controllers\AuthController;
use Engelsystem\Controllers\OAuthController;
use Engelsystem\Helpers\Authenticator;
use Engelsystem\Http\Exceptions\HttpNotFound;
use Engelsystem\Http\Redirector;
use Engelsystem\Http\Request;
use Engelsystem\Http\Response;
use Engelsystem\Http\UrlGenerator;
use Engelsystem\Models\OAuth;
use Engelsystem\Models\User\User;
use Engelsystem\Test\Unit\HasDatabase;
use Engelsystem\Test\Unit\TestCase;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Provider\GenericProvider;
use League\OAuth2\Client\Provider\ResourceOwnerInterface;
use League\OAuth2\Client\Token\AccessToken;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\Test\TestLogger;
use Symfony\Component\HttpFoundation\Session\Session as Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
class OAuthControllerTest extends TestCase
{
use HasDatabase;
/** @var Authenticator|MockObject */
protected $auth;
/** @var AuthController|MockObject */
protected $authController;
/** @var User */
protected $authenticatedUser;
/** @var User */
protected $otherAuthenticatedUser;
/** @var User */
protected $otherUser;
/** @var Config */
protected $config;
/** @var TestLogger */
protected $log;
/** @var OAuth */
protected $oauth;
/** @var Redirector|MockObject $redirect */
protected $redirect;
/** @var Session */
protected $session;
/** @var UrlGenerator|MockObject */
protected $url;
/** @var string[][] */
protected $oauthConfig = [
'testprovider' => [
'client_id' => 'testsystem',
'client_secret' => 'foo-bar-baz',
'url_auth' => 'http://localhost/auth',
'url_token' => 'http://localhost/token',
'url_info' => 'http://localhost/info',
'id' => 'uid',
'username' => 'user',
'email' => 'email',
'first_name' => 'given-name',
'last_name' => 'last-name',
'url' => 'http://localhost/',
],
];
/**
* @covers \Engelsystem\Controllers\OAuthController::__construct
* @covers \Engelsystem\Controllers\OAuthController::index
* @covers \Engelsystem\Controllers\OAuthController::handleArrive
*/
public function testIndexArrive()
{
$request = new Request();
$request = $request
->withAttribute('provider', 'testprovider')
->withQueryParams(['code' => 'lorem-ipsum-code', 'state' => 'some-internal-state']);
$this->session->set('oauth2_state', 'some-internal-state');
$this->session->set('oauth2_connect_provider', 'testprovider');
$accessToken = $this->createMock(AccessToken::class);
/** @var ResourceOwnerInterface|MockObject $resourceOwner */
$resourceOwner = $this->createMock(ResourceOwnerInterface::class);
$this->setExpects($resourceOwner, 'toArray', null, [], $this->atLeastOnce());
$resourceOwner->expects($this->exactly(7))
->method('getId')
->willReturnOnConsecutiveCalls(
'other-provider-user-identifier',
'other-provider-user-identifier',
'other-provider-user-identifier',
'other-provider-user-identifier',
'provider-user-identifier',
'provider-user-identifier',
'provider-user-identifier'
);
/** @var GenericProvider|MockObject $provider */
$provider = $this->createMock(GenericProvider::class);
$this->setExpects(
$provider,
'getAccessToken',
['authorization_code', ['code' => 'lorem-ipsum-code']],
$accessToken,
$this->atLeastOnce()
);
$this->setExpects($provider, 'getResourceOwner', [$accessToken], $resourceOwner, $this->atLeastOnce());
$this->authController->expects($this->atLeastOnce())
->method('loginUser')
->willReturnCallback(function (User $user) {
$this->assertTrue(in_array(
$user->id,
[$this->authenticatedUser->id, $this->otherUser->id]
));
return new Response();
});
$this->auth->expects($this->exactly(4))
->method('user')
->willReturnOnConsecutiveCalls(
$this->otherUser,
null,
null,
null
);
$controller = $this->getMock(['getProvider', 'addNotification']);
$this->setExpects($controller, 'getProvider', ['testprovider'], $provider, $this->atLeastOnce());
$this->setExpects($controller, 'addNotification', ['oauth.connected']);
// Connect to provider
$controller->index($request);
$this->assertTrue($this->log->hasInfoThatContains('Connected OAuth'));
$this->assertCount(1, $this->otherUser->oauth);
// Login using provider
$controller->index($request);
$this->assertFalse($this->session->has('oauth2_connect_provider'));
$this->assertFalse((bool)User::find(1)->state->arrived);
// Mark as arrived
$oauthConfig = $this->config->get('oauth');
$oauthConfig['testprovider']['mark_arrived'] = true;
$this->config->set('oauth', $oauthConfig);
$controller->index($request);
$this->assertTrue((bool)User::find(1)->state->arrived);
$this->assertTrue($this->log->hasInfoThatContains('as arrived'));
$this->log->reset();
// Don't set arrived if already done
$controller->index($request);
$this->assertTrue((bool)User::find(1)->state->arrived);
$this->assertFalse($this->log->hasInfoThatContains('as arrived'));
}
/**
* @covers \Engelsystem\Controllers\OAuthController::index
* @covers \Engelsystem\Controllers\OAuthController::getProvider
*/
public function testIndexRedirectToProvider()
{
$this->redirect->expects($this->once())
->method('to')
->willReturnCallback(function ($url) {
$this->assertStringStartsWith('http://localhost/auth', $url);
$this->assertStringContainsString('testsystem', $url);
$this->assertStringContainsString('code', $url);
return new Response();
});
$this->setExpects($this->url, 'to', ['oauth/testprovider'], 'http://localhost/oauth/testprovider');
$request = new Request();
$request = $request
->withAttribute('provider', 'testprovider');
$controller = $this->getMock();
$controller->index($request);
$this->assertNotEmpty($this->session->get('oauth2_state'));
}
/**
* @covers \Engelsystem\Controllers\OAuthController::index
*/
public function testIndexInvalidState()
{
/** @var GenericProvider|MockObject $provider */
$provider = $this->createMock(GenericProvider::class);
$this->session->set('oauth2_state', 'some-internal-state');
$request = new Request();
$request = $request
->withAttribute('provider', 'testprovider')
->withQueryParams(['code' => 'lorem-ipsum-code', 'state' => 'some-wrong-state']);
$controller = $this->getMock(['getProvider']);
$this->setExpects($controller, 'getProvider', ['testprovider'], $provider);
$exception = null;
try {
$controller->index($request);
} catch (HttpNotFound $e) {
$exception = $e;
}
$this->assertFalse($this->session->has('oauth2_state'));
$this->log->hasWarningThatContains('Invalid');
$this->assertNotNull($exception, 'Exception not thrown');
$this->assertEquals('oauth.invalid-state', $exception->getMessage());
}
/**
* @covers \Engelsystem\Controllers\OAuthController::index
*/
public function testIndexProviderError()
{
/** @var GenericProvider|MockObject $provider */
$provider = $this->createMock(GenericProvider::class);
$provider->expects($this->once())
->method('getAccessToken')
->with('authorization_code', ['code' => 'lorem-ipsum-code'])
->willThrowException(new IdentityProviderException(
'Oops',
42,
['error' => 'some_error', 'error_description' => 'Some kind of error']
));
$this->session->set('oauth2_state', 'some-internal-state');
$request = new Request();
$request = $request
->withAttribute('provider', 'testprovider')
->withQueryParams(['code' => 'lorem-ipsum-code', 'state' => 'some-internal-state']);
$controller = $this->getMock(['getProvider']);
$this->setExpects($controller, 'getProvider', ['testprovider'], $provider);
$exception = null;
try {
$controller->index($request);
} catch (HttpNotFound $e) {
$exception = $e;
}
$this->log->hasErrorThatContains('Some kind of error');
$this->log->hasErrorThatContains('some_error');
$this->assertNotNull($exception, 'Exception not thrown');
$this->assertEquals('oauth.provider-error', $exception->getMessage());
}
/**
* @covers \Engelsystem\Controllers\OAuthController::index
*/
public function testIndexAlreadyConnectedToAUser()
{
$accessToken = $this->createMock(AccessToken::class);
/** @var ResourceOwnerInterface|MockObject $resourceOwner */
$resourceOwner = $this->createMock(ResourceOwnerInterface::class);
$this->setExpects($resourceOwner, 'getId', null, 'provider-user-identifier', $this->atLeastOnce());
/** @var GenericProvider|MockObject $provider */
$provider = $this->createMock(GenericProvider::class);
$this->setExpects(
$provider,
'getAccessToken',
['authorization_code', ['code' => 'lorem-ipsum-code']],
$accessToken,
$this->atLeastOnce()
);
$this->setExpects($provider, 'getResourceOwner', [$accessToken], $resourceOwner);
$this->session->set('oauth2_state', 'some-internal-state');
$this->setExpects($this->auth, 'user', null, $this->otherAuthenticatedUser);
$request = new Request();
$request = $request
->withAttribute('provider', 'testprovider')
->withQueryParams(['code' => 'lorem-ipsum-code', 'state' => 'some-internal-state']);
$controller = $this->getMock(['getProvider']);
$this->setExpects($controller, 'getProvider', ['testprovider'], $provider);
$exception = null;
try {
$controller->index($request);
} catch (HttpNotFound $e) {
$exception = $e;
}
$this->assertNotNull($exception, 'Exception not thrown');
$this->assertEquals('oauth.already-connected', $exception->getMessage());
}
/**
* @covers \Engelsystem\Controllers\OAuthController::index
* @covers \Engelsystem\Controllers\OAuthController::redirectRegisterOrThrowNotFound
*/
public function testIndexRedirectRegister()
{
$accessToken = $this->createMock(AccessToken::class);
/** @var ResourceOwnerInterface|MockObject $resourceOwner */
$resourceOwner = $this->createMock(ResourceOwnerInterface::class);
$this->setExpects(
$resourceOwner,
'getId',
null,
'provider-not-connected-identifier',
$this->atLeastOnce()
);
$this->setExpects(
$resourceOwner,
'toArray',
null,
[
'uid' => 'provider-not-connected-identifier',
'user' => 'username',
'email' => 'foo.bar@localhost',
'given-name' => 'Foo',
'last-name' => 'Bar',
],
$this->atLeastOnce()
);
/** @var GenericProvider|MockObject $provider */
$provider = $this->createMock(GenericProvider::class);
$this->setExpects(
$provider,
'getAccessToken',
['authorization_code', ['code' => 'lorem-ipsum-code']],
$accessToken,
$this->atLeastOnce()
);
$this->setExpects($provider, 'getResourceOwner', [$accessToken], $resourceOwner, $this->atLeastOnce());
$this->session->set('oauth2_state', 'some-internal-state');
$this->setExpects($this->auth, 'user', null, null, $this->atLeastOnce());
$this->setExpects($this->redirect, 'to', ['/register']);
$request = new Request();
$request = $request
->withAttribute('provider', 'testprovider')
->withQueryParams(['code' => 'lorem-ipsum-code', 'state' => 'some-internal-state']);
$controller = $this->getMock(['getProvider']);
$this->setExpects($controller, 'getProvider', ['testprovider'], $provider, $this->atLeastOnce());
$this->config->set('registration_enabled', true);
$controller->index($request);
$this->assertEquals('testprovider', $this->session->get('oauth2_connect_provider'));
$this->assertEquals('provider-not-connected-identifier', $this->session->get('oauth2_user_id'));
$this->assertEquals(
[
'name' => 'username',
'email' => 'foo.bar@localhost',
'first_name' => 'Foo',
'last_name' => 'Bar',
],
$this->session->get('form_data')
);
$this->config->set('registration_enabled', false);
$this->expectException(HttpNotFound::class);
$controller->index($request);
}
/**
* @covers \Engelsystem\Controllers\OAuthController::connect
* @covers \Engelsystem\Controllers\OAuthController::requireProvider
* @covers \Engelsystem\Controllers\OAuthController::isValidProvider
*/
public function testConnect()
{
$controller = $this->getMock(['index']);
$this->setExpects($controller, 'index', null, new Response());
$request = (new Request())
->withAttribute('provider', 'testprovider');
$controller->connect($request);
$this->assertEquals('testprovider', $this->session->get('oauth2_connect_provider'));
// Provider not found
$request = $request->withAttribute('provider', 'notExistingProvider');
$this->expectException(HttpNotFound::class);
$controller->connect($request);
}
/**
* @covers \Engelsystem\Controllers\OAuthController::disconnect
*/
public function testDisconnect()
{
$controller = $this->getMock(['addNotification']);
$this->setExpects($controller, 'addNotification', ['oauth.disconnected']);
$request = (new Request())
->withAttribute('provider', 'testprovider');
$this->setExpects($this->auth, 'user', null, $this->authenticatedUser);
$this->setExpects($this->redirect, 'back', null, new Response());
$controller->disconnect($request);
$this->assertCount(1, OAuth::all());
$this->log->hasInfoThatContains('Disconnected');
}
/**
* @param array $mockMethods
*
* @return OAuthController|MockObject
*/
protected function getMock(array $mockMethods = []): OAuthController
{
/** @var OAuthController|MockObject $controller */
$controller = $this->getMockBuilder(OAuthController::class)
->setConstructorArgs([
$this->auth,
$this->authController,
$this->config,
$this->log,
$this->oauth,
$this->redirect,
$this->session,
$this->url
])
->onlyMethods($mockMethods)
->getMock();
return $controller;
}
/**
* Setup the DB
*/
public function setUp(): void
{
parent::setUp();
$this->initDatabase();
$this->auth = $this->createMock(Authenticator::class);
$this->authController = $this->createMock(AuthController::class);
$this->config = new Config(['oauth' => $this->oauthConfig]);
$this->log = new TestLogger();
$this->oauth = new OAuth();
$this->redirect = $this->createMock(Redirector::class);
$this->session = new Session(new MockArraySessionStorage());
$this->url = $this->createMock(UrlGenerator::class);
$this->app->instance('session', $this->session);
$this->authenticatedUser = new User([
'name' => 'foo',
'password' => '',
'email' => 'foo@localhost',
'api_key' => '',
'last_login_at' => null,
]);
$this->authenticatedUser->save();
(new OAuth(['provider' => 'testprovider', 'identifier' => 'provider-user-identifier']))
->user()
->associate($this->authenticatedUser)
->save();
$this->otherUser = new User([
'name' => 'bar',
'password' => '',
'email' => 'bar@localhost',
'api_key' => '',
'last_login_at' => null,
]);
$this->otherUser->save();
$this->otherAuthenticatedUser = new User([
'name' => 'baz',
'password' => '',
'email' => 'baz@localhost',
'api_key' => '',
'last_login_at' => null,
]);
$this->otherAuthenticatedUser->save();
(new OAuth(['provider' => 'testprovider', 'identifier' => 'provider-baz-identifier']))
->user()
->associate($this->otherAuthenticatedUser)
->save();
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace Engelsystem\Test\Unit\Controllers;
use Engelsystem\Config\Config;
use Engelsystem\Controllers\SettingsController;
use Engelsystem\Http\Exceptions\HttpNotFound;
use Engelsystem\Http\Response;
use Engelsystem\Test\Unit\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
class SettingsControllerTest extends TestCase
{
/**
* @covers \Engelsystem\Controllers\SettingsController::__construct
* @covers \Engelsystem\Controllers\SettingsController::oauth
*/
public function testOauth()
{
$providers = ['foo' => ['lorem' => 'ipsum']];
$config = new Config(['oauth' => $providers]);
$session = new Session(new MockArraySessionStorage());
$session->set('information', [['lorem' => 'ipsum']]);
$this->app->instance('session', $session);
/** @var Response|MockObject $response */
$response = $this->createMock(Response::class);
$response->expects($this->once())
->method('withView')
->willReturnCallback(function ($view, $data) use ($response, $providers) {
$this->assertEquals('pages/settings/oauth.twig', $view);
$this->assertArrayHasKey('information', $data);
$this->assertArrayHasKey('providers', $data);
$this->assertEquals($providers, $data['providers']);
return $response;
});
$controller = new SettingsController($config, $response);
$controller->oauth();
}
/**
* @covers \Engelsystem\Controllers\SettingsController::oauth
*/
public function testOauthNotConfigured()
{
$config = new Config(['oauth' => []]);
/** @var Response|MockObject $response */
$response = $this->createMock(Response::class);
$controller = new SettingsController($config, $response);
$this->expectException(HttpNotFound::class);
$controller->oauth();
}
}

View File

@ -7,6 +7,7 @@ use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
use Engelsystem\Models\BaseModel;
use Engelsystem\Models\News;
use Engelsystem\Models\NewsComment;
use Engelsystem\Models\OAuth;
use Engelsystem\Models\Question;
use Engelsystem\Models\User\Contact;
use Engelsystem\Models\User\HasUserModel;
@ -165,6 +166,24 @@ class UserTest extends ModelTest
$this->assertSame($newsComment->id, $comment->id);
}
/**
* Tests that accessing OAuth of an User works
*
* @covers \Engelsystem\Models\User\User::oauth
*/
public function testOauth(): void
{
($user = new User($this->data))->save();
(new OAuth(['provider' => 'test', 'identifier' => 'LoremIpsumDolor123']))
->user()
->associate($user)
->save();
$oauth = $user->oauth;
$this->assertCount(1, $oauth);
}
/**
* @covers \Engelsystem\Models\User\User::worklogs
*/

View File

@ -3,6 +3,7 @@
namespace Engelsystem\Test\Unit\Renderer\Twig\Extensions;
use Engelsystem\Helpers\Authenticator;
use Engelsystem\Http\Request;
use Engelsystem\Models\User\User;
use Engelsystem\Renderer\Twig\Extensions\Globals;
use PHPUnit\Framework\MockObject\MockObject;
@ -17,6 +18,8 @@ class GlobalsTest extends ExtensionTest
{
/** @var Authenticator|MockObject $auth */
$auth = $this->createMock(Authenticator::class);
/** @var Request|MockObject $request */
$request = $this->createMock(Request::class);
$user = new User();
$auth->expects($this->exactly(2))
@ -26,10 +29,11 @@ class GlobalsTest extends ExtensionTest
$user
);
$extension = new Globals($auth);
$extension = new Globals($auth, $request);
$globals = $extension->getGlobals();
$this->assertGlobalsExists('user', [], $globals);
$this->assertGlobalsExists('request', $request, $globals);
$globals = $extension->getGlobals();
$this->assertGlobalsExists('user', $user, $globals);