Browse Source

commit before some refactoring to basically just make authkit2 the

entrypoint to the library
master
Adam Pippin 3 years ago
parent
commit
5e4afc5ae5
  1. 13
      .editorconfig
  2. 2
      .gitignore
  3. 64
      .phan/config.php
  4. 0
      .phan/stubs/.gitkeep
  5. 330
      .php_cs.dist
  6. 42
      composer.json
  7. 5079
      composer.lock
  8. 70
      config/authkit.php
  9. 42
      database/migrations/existing/authkit2_users_update_minimal.php
  10. 43
      database/migrations/new/authkit2_users_update.php
  11. 9
      routes/web.php
  12. 144
      src/Authkit2.php
  13. 34
      src/Events/UserEvent.php
  14. 12
      src/Events/UserLogin.php
  15. 12
      src/Events/UserLogout.php
  16. 13
      src/Events/UserRegistration.php
  17. 133
      src/Http/Controllers/AuthenticationController.php
  18. 14
      src/Http/Controllers/Controller.php
  19. 9
      src/Models/IAuthkitUser.php
  20. 29
      src/Models/User.php
  21. 34
      src/Observers/UserObserver.php
  22. 14
      src/Oidc/Flows/UserFlow.php
  23. 56
      src/Oidc/Token.php
  24. 57
      src/Providers/Authkit2ServiceProvider.php
  25. 122
      src/Providers/AuthnServiceProvider.php
  26. 22
      src/Providers/AuthzServiceProvider.php

13
.editorconfig

@ -0,0 +1,13 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = tab
[*.md]
trim_trailing_whitespace = false
[*.yml]
indent_style = space
indent_size = 2

2
.gitignore

@ -0,0 +1,2 @@
/vendor
/.php_cs.cache

64
.phan/config.php

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
return [
'target_php_version' => '7.4',
'directory_list' => [
'.phan/stubs/',
'src/',
'vendor/laravel/',
'vendor/composer/',
'vendor/symfony/'
],
'exclude_analysis_directory_list' => [
'vendor/',
'.phan/stubs/'
],
'plugins' => [
// stricter checks on whether a function returns the proper type
'AlwaysReturnPlugin',
// warn about trying to set duplicate keys in arrays (common mistake)
// warn about duplicate switch case values
// warn about mixing arrays/dictionaries
'DuplicateArrayKeyPlugin',
// validate regular expressions
'PregRegexCheckerPlugin',
// validate printf statements
'PrintfCheckerPlugin',
// Check for unreachable code in functions
'UnreachableCodePlugin',
// warn if you're calling a function where you probably should be using
// the return value (e.g., printf) but are not
'UseReturnValuePlugin',
// Infer values from phpunit tests
//'PHPUnitAssertionPlugin'
// Warn if structures are missing phpdoc plugins
'HasPHPDocPlugin',
// warn on isset(func()['index']) and isset($array[$key]) where $array is not set
//'InvalidVariableIssetPlugin'
// warn about returning non-array values from __sleep
'SleepCheckerPlugin',
// warn about unknown return/parameter type (not documented and unable to
// be inferred)
'UnknownElementTypePlugin',
// warn about expressions that are likely to be a bug (e.g., a == a)
'DuplicateExpressionPlugin',
// try and use some heuristics to detect if parameters are out of order
// on some function calls
//'SuspiciousParamOrderPlugin',
],
'plugin_config' => [
// UseReturnValuePlugin -- slow; check and see if the return value of a
// function is *normally* used and if so, warn where it is not.
//'use_return_value_dynamic_checks'=>true
//'use_return_value_warn_threshold_percentage'=>98,
],
'minimum_severity' => 0
];

0
.phan/stubs/.gitkeep

330
.php_cs.dist

@ -0,0 +1,330 @@
<?php
$finder = Symfony\Component\Finder\Finder::create()
->exclude('bootstrap/cache')
->exclude('storage')
->exclude('vendor')
->in(__DIR__)
->name('*.php')
->ignoreDotFiles(true)
->ignoreVCS(true)
;
return PhpCsFixer\Config::create()
->setRules([
'align_multiline_comment' => [
'comment_type' => 'phpdocs_only'
],
'array_indentation' => true,
'array_syntax' => [
'syntax' => 'short'
],
'binary_operator_spaces' => [
'default' => 'single_space'
],
'blank_line_after_namespace' => true,
'blank_line_after_opening_tag' => true,
//'blank_line_before_statement' => [
// 'statements' => ['break', 'case', 'continue', 'declare', 'default', 'die', 'do', 'exit', 'for', 'foreach', 'goto', 'if', 'include', 'include_once', 'require', 'require_once', 'return', 'switch', 'throw', 'try', 'while', 'yield']
// ]
'braces' => [
'allow_single_line_closure' => true,
'position_after_anonymous_constructs' => 'same',
'position_after_control_structures' => 'next',
'position_after_functions_and_oop_constructs' => 'next'
],
'cast_spaces' => [
'space' => 'none'
],
'class_attributes_separation' => [
'elements' => ['const', 'method', 'property']
],
'class_definition' => [
'multi_line_extends_each_single_line' => false,
'single_item_single_line' => false,
'single_line' => false
],
'class_keyword_remove' => false,
'combine_consecutive_issets' => false,
'combine_consecutive_unsets' => true,
'combine_nested_dirname' => true,
'comment_to_phpdoc' => true,
'compact_nullable_typehint' => true,
'concat_space' => [
'spacing' => 'none'
],
//'date_time_immutable' => false,
'declare_equal_normalize' => [
'space'=>'none'
],
'declare_strict_types' => true,
'dir_constant' => true,
'elseif' => true,
'encoding' => true,
'ereg_to_preg' => true,
'error_suppression' => [
'mute_deprecation_error' => false,
'noise_remaining_usages' => true,
'noise_remaining_usages_exclude' => [] // functions to exclude
],
'escape_implicit_backslashes' => [
'double_quoted' => true,
'heredoc_syntax' => true,
'single_quoted' => true
],
'explicit_indirect_variable' => true,
'explicit_string_variable' => false,
//'final_class' => true, // mark all non-abstract classes as final
//'final_internal_class' => [ see doc for opts ] // mark all internal classes as final
'fopen_flag_order' => true,
'fopen_flags' => [
'b_mode' => true
],
'full_opening_tag' => true,
//'fully_qualified_strict_types' => ???,
'function_declaration' => [
'closure_function_spacing' => 'none'
],
//'function_to_constant' => [
// replaces get_called_class, get_class, php_sapi_name, phpversion, pi with
// constants... not sure how that could be valid?
//],
'function_typehint_space' => true,
'general_phpdoc_annotation_remove' => [
'annotations' => [] // list of @annotations to remove form phpdoc comments
],
//'heredoc_indentation' => true, // requires php 7.3
'heredoc_to_nowdoc' => true,
'implode_call' => true,
'include' => true,
//'increment_style' => [
// 'style' => // post/pre
//],
'indentation_type' => true,
'is_null' => true,
'line_ending' => true,
'linebreak_after_opening_tag' => true,
'list_syntax' => [
'syntax' => 'short'
],
'logical_operators' => true,
'lowercase_cast' => true,
'lowercase_constants' => true,
'lowercase_keywords' => true,
'lowercase_static_reference' => true,
'magic_constant_casing' => true,
'magic_method_casing' => true,
//'mb_str_function' => true, // replace non-mb safe with mb_ methods
'method_argument_space' => [
'keep_multiple_spaces_after_comma' => false,
'on_multiline' => 'ignore' // ensure_fully_multiline, ensure_single_line, ignore
],
'method_chaining_indentation' => true,
'modernize_types_casting' => true,
'multiline_comment_opening_closing' => true,
'multiline_whitespace_before_semicolons' => [
'strategy' => 'new_line_for_chained_calls'
],
'native_constant_invocation' => [],
'native_function_casing' => true,
'native_function_invocation' => [],
'native_function_type_declaration_casing' => true,
//'new_with_brances' => true, // no idea what this does? included in @Symfony bundle
'no_alias_functions' => [],
'no_alternative_syntax' => true, // get rid of while {} endwhile; stuff
'no_binary_string' => true,
'no_blank_lines_after_class_opening' => true,
'no_blank_lines_after_phpdoc' => true,
//'no_blank_lines_before_namespace' => true,
'no_break_comment' => [
'comment_text' => 'no break'
],
'no_closing_tag' => true,
'no_empty_comment' => true,
'no_empty_phpdoc' => true,
'no_empty_statement' => true,
'no_extra_blank_lines' => [
'tokens' => ['extra']
],
'no_homoglyph_names' => true,
'no_leading_import_slash' => true,
'no_leading_namespace_whitespace' => true,
'no_mixed_echo_print' => [
'use' => 'echo'
],
'no_multiline_whitespace_around_double_arrow' => true,
'no_null_property_initialization' => false,
//'no_php4_constructor' => true,
'no_short_bool_cast' => true,
'no_short_echo_tag' => false,
'no_singleline_whitespace_before_semicolons' => true,
'no_spaces_after_function_name' => true,
'no_spaces_around_offset' => [
'positions' => ['inside', 'outside']
],
'no_spaces_inside_parenthesis' => true,
//'no_superfluous_elseif' => false, // don't know what this would do?
//'no_superfluous_phpdoc_tags' => [
// removes @param/@return that "don't provide any useful information"
//],
'no_trailing_comma_in_list_call' => true,
'no_trailing_comma_in_singleline_array' => true,
'no_trailing_whitespace' => true,
'no_trailing_whitespace_in_comment' => true,
'no_unneeded_control_parentheses' => true,
'no_unneeded_curly_braces' => true,
'no_unneeded_final_method' => true,
'no_unreachable_default_argument_value' => true,
'no_unset_cast' => true,
'no_unset_on_property' => false,
'no_unused_imports' => true,
'no_useless_else' => true,
'no_useless_return' => true,
'no_whitespace_before_comma_in_array' => [
'after_heredoc' => false
],
'no_whitespace_in_blank_line' => true,
'non_printable_character' => [
'use_escape_sequences_in_strings' => true // don't just remove them, make them visible to the programmer and let them sort it out
],
'normalize_index_brace' => true,
'not_operator_with_space' => false,
'not_operator_with_successor_space' => false,
'object_operator_without_whitespace' => true,
//'ordered_class_elements' => [ // lets us sort methods/props/etc in classes
// 'order' => [],
// 'sortAlgorithm'=>''
//],
//'ordered_imports' => [ // sort use statements, off so we can retain some sort of context
// 'sort_algorithm' => 'alpha'
//],
//'ordered_interfaces' => [ // sort implements or interface extends
//],
'php_unit_construct' => [ // replace phpunit's ->assertSame(true, $foo) with ->assertTrue($foo)
],
'php_unit_dedicate_assert' => [ // try and replace things like assertTrue(file_exists()) with assertFileExists
'target'=>'newest'
],
'php_unit_dedicate_assert_internal_type' => [
'target'=>'newest'
],
'php_unit_expectation' => [ // should use expectException instead of setExpectedException
'target' => 'newest'
],
'php_unit_fqcn_annotation' => true,
'php_unit_internal_class' => [ // phpunit tests should be marked internal
'types' => ['normal', 'final']
],
'php_unit_mock' => [ // use createMock instead of getMock
'target' => 'newest'
],
'php_unit_mock_short_will_return' => true,
'php_unit_namespaced' => true,
'php_unit_no_expectation_annotation' => [
'target'=>'newest'
],
'php_unit_ordered_covers' => true,
'php_unit_set_up_tear_down_visibility' => true,
//'php_unit_size_class' => true, // tests should have @small/@medium/@large annotation
'php_unit_strict' => [
],
'php_unit_test_annotation' => [ // add @test annotation to tests
],
'php_unit_test_case_static_method_calls' => [
],
'php_unit_test_class_requires_covers' => true,
'phpdoc_add_missing_param_annotation' => [
'only_untyped' => false
],
'phpdoc_align' => [
'align' => 'vertical'
],
'phpdoc_annotation_without_dot' => true,
'phpdoc_indent' => true,
'phpdoc_inline_tag' => true,
'phpdoc_no_access' => true,
//'phpdoc_no_alias_tag' => [],
'phpdoc_no_empty_return' => false,
'phpdoc_no_package' => true,
'phpdoc_no_useless_inheritdoc' => true,
'phpdoc_order' => true,
'phpdoc_return_self_reference' => [],
'phpdoc_scalar' => true,
'phpdoc_separation' => false,
'phpdoc_single_line_var_spacing' => true,
'phpdoc_summary' => true,
//'phpdoc_to_comment' => false, // we sometimes use these for phan suppression and stuff
//'phpdoc_to_return_type' => [ 'scalar_types' => true ],
'phpdoc_trim' => true,
'phpdoc_trim_consecutive_blank_line_separation' => true,
'phpdoc_types' => [ // fix casing of types in phpdoc
'groups' => ['simple', 'alias', 'meta']
],
'phpdoc_types_order' => [
'null_adjustment' => 'always_last',
'sort_algorithm' => 'none'
],
'phpdoc_var_annotation_correct_order' => true,
'phpdoc_var_without_name' => true,
'pow_to_exponentiation' => true,
//'protected_to_private' => true, // convert protected methods to private ones
'psr4' => true, // class name should match filename
'random_api_migration' => [ // replace rand/srand/etc with the mt_ funcs
],
'return_assignment' => true,
'return_type_declaration' => [
'space_before' => 'none'
],
//'self_accessor' => false, // force use of self instead of class name
'semicolon_after_instruction' => true,
'set_type_to_cast' => true, // use casting not settype
'short_scalar_cast' => true, // use bool not boolean, int not integer, etc
'simple_to_complex_string_variable' => false, // convert ${var} to {$var} in strings
'simplified_null_return' => true, // don't `return null`, just `return`
'single_blank_line_at_eof' => true,
'single_blank_line_before_namespace' => true,
'single_class_element_per_statement' => [
'elements' => ['const', 'property']
],
'single_import_per_statement' => true,
'single_line_after_imports' => true,
'single_line_comment_style' => [
'comment_types' => ['asterisk', 'hash']
],
'single_quote' => [
'strings_containing_single_quote_chars' => false
],
'single_trait_insert_per_statement' => true,
'space_after_semicolon' => [
'remove_in_empty_for_expressions' => false
],
'standardize_increment' => true,
'standardize_not_equals' => true,
'static_lambda' => true,
//'strict_comparsion' => true, // would be good to *check* but not change
//'strict_param' => true,
//'string_line_ending' => true, // could screw up literals that are used for comparison to outside sourced data
'switch_case_semicolon_to_colon' => true,
'switch_case_space' => true,
'ternary_operator_spaces' => true,
'ternary_to_null_coalescing' => true,
//'trailing_comma_in_multiline_array' => [ // no option to *remove*
//],
'trim_array_spaces' => true,
'unary_operator_spaces' => true,
'visibility_required' => [
'elements' => ['property', 'method', 'const']
],
//'void_return' => true, // adds a void return type to functions without a @return/:return
'whitespace_after_comma_in_array' => true,
//'yoda_style' => [ // would be a good habit but I find it hard to read
// 'always_move_variable' => false,
// 'equal' => false,
// 'identical' => false,
// 'less_and_greater' => false
//]
])
->setIndent("\t")
->setLineEnding("\n")
->setFinder($finder)
;

42
composer.json

@ -1,12 +1,34 @@
{
"require": {
"guzzlehttp/guzzle": "^7.2",
"psr/simple-cache": "^1.0",
"firebase/php-jwt": "^5.2"
},
"autoload": {
"psr-4": {
"authkit2\\": "src"
}
}
"name": "cmg/authkit2",
"description": "authn/authz toolkit",
"type": "library",
"license": "proprietary",
"authors": [
{
"name": "Adam Pippin",
"email": "adam.pippin@createmusicgroup.com"
}
],
"require": {
"guzzlehttp/guzzle": "^7.2",
"psr/simple-cache": "^1.0",
"firebase/php-jwt": "^5.2"
},
"autoload": {
"psr-4": {
"authkit2\\": "src"
}
},
"extra": {
"laravel": {
"providers": [
"authkit2\\Providers\\Authkit2ServiceProvider"
]
}
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.18",
"phan/phan": "^4.0",
"laravel/framework": "^8.31"
}
}

5079
composer.lock

File diff suppressed because it is too large

70
config/authkit.php

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
return [
'authn' => [
/*
* Enable/disable the authentication component
*
* When disabled, the authentication service provider, routes,
* and all other components of the authentication system are not
* registered with Laravel.
*/
'enable' => true,
// Scopes to request from the OIDC service
'scopes' => ['email'],
/*
* Path to the OIDC config
*
* This uses the Laravel storage driver, so this needs to be configured to
* use one of the filesystems set up in config/filesystems.php.
*/
'config' => [
'disk' => 'local',
'path' => 'auth.json'
],
/*
* Customize registered routes
*
* All routes are registered with \Route::group(this_array, function() {})
* At minimum, you should specify 'middleware' and 'prefix' though you
* can specify any options that Laravel's router will accept.
*/
'routing' => [
'middleware' => 'web',
'prefix' => '/auth'
],
/*
* URL to redirect the user to at various points in the process
*
* These are run through Laravel's url() helper, so can be relative (in which
* case they will respect the app's current URL/APP_URL/etc) or absolute.
*
* post_login: After a successful login
* post_logout: After a successful logout. Must be configured in SSO service.
*/
'urls' => [
'post_login' => '/',
'post_logout' => '/'
]
],
'authz' => [
/*
* Enable/disable the authorization component
*
* Currently authorization is not implemented.
*/
'enable' => true,
'provider' => [
'url' => env('AUTHZ_HOST', 'http://localhost:4466/')
]
]
];

42
database/migrations/existing/authkit2_users_update_minimal.php

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class Authkit2UsersUpdateMinimal extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
// These are split up like this as to not cause issues when running
// against sqlite.
Schema::table('users', static function(Blueprint $table) {
$table->string('password')->nullable()->change();
});
Schema::table('users', static function(BluePrint $table) {
$table->string('authkit_id')->unique()->nullable();
$table->text('authkit_access_token')->nullable();
$table->text('authkit_refresh_token')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', static function(Blueprint $table) {
$table->dropColumn(['authkit_id', 'authkit_access_token', 'authkit_refresh_token']);
$table->string('password')->nullable(false)->default(null)->change();
});
}
}

43
database/migrations/new/authkit2_users_update.php

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class Authkit2UsersUpdate extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
// These are split up like this as to not cause issues when running
// against sqlite.
Schema::table('users', static function(Blueprint $table) {
$table->dropColumn(['email_verified_at', 'password']);
});
Schema::table('users', static function(Blueprint $table) {
$table->string('authkit_id')->unique();
$table->text('authkit_access_token');
$table->text('authkit_refresh_token');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', static function(Blueprint $table) {
$table->dropColumn(['authkit_id', 'authkit_access_token', 'authkit_refresh_token']);
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
});
}
}

9
routes/web.php

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
use authkit2\Http\Controllers\AuthenticationController;
\Route::get('/login', [AuthenticationController::class, 'login'])->name('login');
\Route::get('/logout', [AuthenticationController::class, 'logout'])->name('logout');
\Route::get('/callback', [AuthenticationController::class, 'callback'])->name('login.callback');

144
src/Authkit2.php

@ -2,12 +2,40 @@
namespace authkit2;
/**
* Internal class for providing a single interface integrating with native PHP,
* Laravel, and any other environment we need to support.
*
* @method static mixed cache(string $key, callable $generator)
* @method static mixed cache_get(string $key, mixed $default = null)
* @method static void cache_set(string $key, mixed $value)
* @method static mixed session_get(string $key)
* @method static void session_set(string $key, mixed $value)
*/
class Authkit2
{
const LIB_PREFIX = 'authkit2.';
protected $callbacks;
/**
* All data we shove into the session/cache will have its key prefixed
* with this value.
* @var string
*/
private const LIB_PREFIX = 'authkit2.';
/**
* Functions this class provides
*
* @array<string,callable>
*/
protected $callbacks = [];
/**
* Try and detect if we recognize the environment the library is running
* in and adjust our implementations accordingly.
*
* Basically, if we see the LARAVEL_START constant we assume Laravel and
* use Laravel facades, otherwise we use native php implementations.
*
*/
protected function __construct()
{
if (defined('LARAVEL_START'))
@ -20,7 +48,12 @@ class Authkit2
}
}
public static function get()
/**
* Retrieve the instance of Authkit2 class
*
* @return Authkit2
*/
public static function get(): Authkit2
{
static $authkit2;
if (!isset($authkit2))
@ -30,7 +63,17 @@ class Authkit2
return $authkit2;
}
public function __set(string $name, $value)
/**
* Override any of the function implementations
*
* Name is the same as the callable function name, e.g.,
* Authkit2::cache_set() can be overriden with Authkit2->cache_set = function(...) {}
*
* @param string $name
* @param callable $value
* @return void
*/
public function __set(string $name, $value): void
{
if (!array_key_exists($name, $this->callbacks))
{
@ -46,19 +89,33 @@ class Authkit2
$this->callbacks[$name] = $value;
}
/**
* Call any of the provided methods
*
* @param string $name
* @param mixed[] $arguments
* @return mixed
*/
public static function __callStatic(string $name, array $arguments)
{
$authkit2 = static::get();
if (!isset($authkit2->callbacks[$name]))
{
trigger_error('Call to undefined method '.__CLASS__.'::'.$name.'()', E_USER_ERROR);
return;
}
return call_user_func_array($authkit2->callbacks[$name], $arguments);
}
protected function cache(string $key, callable $generator)
/**
* Helper method for getting cache values, and generating and setting if
* they do not exist.
*
* @param string $key cache key
* @param callable $generator method that returns the value if we do not have it cached
* @return mixed
*/
protected function cache_helper(string $key, callable $generator)
{
$value = static::cache_get($key, null);
if (!isset($value))
@ -69,35 +126,73 @@ class Authkit2
return $value;
}
protected function initializeNative()
/**
* Initialize the class by binding all the PHP native implementations of
* functions
*
* @return void
*/
protected function initializeNative(): void
{
$this->callbacks = [
'session_get' => [$this, 'native_session_get'],
'session_set' => [$this, 'native_session_set'],
'cache_get' => [$this, 'native_cache_get'],
'cache_set' => [$this, 'native_cache_set'],
'cache' => [$this, 'cache']
'cache' => [$this, 'cache_helper']
];
}
protected function initializeLaravel()
/**
* Initialize the class by binding Laravel adapters as the implementation
* of all functions
*
* @return void
*/
protected function initializeLaravel(): void
{
throw new \Exception('TODO: Implement laravel support');
$this->callbacks = [
'session_get' => function(string $key) { return \Session::get($key); },
'session_set' => function(string $key, $value) { \Session::put($key, $value); },
'cache_get' => function(string $key) { return \Cache::get($key); },
'cache_set' => function(string $key, $value) { \Cache::set($key, $value); },
'cache' => [$this, 'cache_helper']
];
}
protected function native_session_get($key)
/**
* Retrieve a property out of the $_SESSION variable; null if the
* property doesn't exist.
*
* @param string $key
* @return mixed
*/
protected function native_session_get(string $key)
{
$this->native_session_check();
return $_SESSION[static::LIB_PREFIX.$key] ?? null;
}
protected function native_session_set($key, $value)
/**
* Set a value in the $_SESSION variable
*
* @param string $key
* @param mixed $value
* @return void
*/
protected function native_session_set(string $key, $value): void
{
$this->native_session_check();
$_SESSION[static::LIB_PREFIX.$key] = $value;
}
protected function native_session_check()
/**
* Check whether a PHP session exists, and if not try and start one
*
* @internal
* @return void
*/
protected function native_session_check(): void
{
if (session_status() == \PHP_SESSION_NONE)
session_start();
@ -105,12 +200,27 @@ class Authkit2
throw new \Exception("Authkit2 requires PHP sessions are enabled");
}
protected function native_cache_get($key, $default = null)
/**
* Dummy cache implementation to avoid errors; always returns default
*
* @todo Check if apcu is available and use if so? Fall back to temp files?
* @param string $key cache key to retrieve
* @param mixed $default value to return if the specified key is not found
* @return mixed
*/
protected function native_cache_get(string $key, $default = null)
{
return $default;
}
protected function native_cache_set($key, $value)
/**
* Dummy cache implementation
*
* @param string $key cache key to set
* @param mixed $value value to cache
* @return void
*/
protected function native_cache_set(string $key, $value): void
{
}

34
src/Events/UserEvent.php

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace authkit2\Events;
use Illuminate\Queue\SerializesModels;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Event providing a user model as context
*/
abstract class UserEvent
{
use Dispatchable;
use SerializesModels;
/**
* User that this event refers to
*
* @var mixed
*/
public $user;
/**
* Initialize new event
*
* @param mixed $user
*/
public function __construct($user)
{
$this->user = $user;
}
}

12
src/Events/UserLogin.php

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace authkit2\Events;
/**
* Notification that a user has logged into the app
*/
class UserLogin extends UserEvent
{
}

12
src/Events/UserLogout.php

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace authkit2\Events;
/**
* Notification that a user has logged into the app
*/
class UserLogout extends UserEvent
{
}

13
src/Events/UserRegistration.php

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace authkit2\Events;
/**
* Notification that a user logging in is brand new
* to this app.
*/
class UserRegistration extends UserEvent
{
}

133
src/Http/Controllers/AuthenticationController.php

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace authkit2\Http\Controllers;
use Illuminate\Http\Request;
/**
* Methods for handling user authentication operations
*/
class AuthenticationController extends Controller
{
protected $user_flow;
public function __construct(\authkit2\Oidc\Flows\UserFlow $user_flow)
{
$this->user_flow = $user_flow;
}
/**
* Start the login flow for a user
*
* Redirects the user to the SSO service
*
* @return mixed
*/
public function login()
{
// TODO: Pass in 'previous' URL from session so we can redirect back
// if we were redirected to login from a guard?
return redirect($this->user_flow->getRedirectUrl(config('authkit.authn.openid.redirect_uri'), config('authkit.authn.scopes')));
}
/**
* Handle the response from the SSO service
*
* Exchange the code for a token and fetches basic user information.
* Attempts to log the user into this app, and creates them if they
* don't exist. Then redirects the user to the configured post_login url.
*
* @TODO fix type hint on request \/
* @param $request
* @return mixed
*/
public function callback(Request $request)
{
// Get the user class from the Laravel auth config
$user_class = config('auth.providers.users.model');
// Verify the passed in state value
$this->user_flow->validateState($request->state);
// TODO: Check for error response
// Exchange the code for a token
$token = $this->user_flow->exchangeCodeForToken(config('authkit.authn.openid.redirect_uri'), $request->code);
$user_info = $token->getUserInfo();
// Try and use the token to find the local user
$user = \Auth::loginUsingId($token->getUserId());
// If that failed
if ($user === false)
{
// User doesn't exist, create them.
$user = new $user_class();
$id_field = $user->getAuthIdentifierName();
$user->{$id_field} = $token->getUserId();
$user->name = $user_info['name'];
$user->email = $user_info['email'];
if ($user instanceof \authkit2\Models\IAuthkitUser)
{
$user->{$user->getAccessTokenName()} = $token->getAccessToken();
$user->{$user->getRefreshTokenName()} = $token->getRefreshToken();
}
else
{
$user->authkit_access_token = $token->getAccessToken();
$user->authkit_refresh_token = $token->getRefreshToken();
}
$register_event_result = event(new \authkit2\Events\UserRegistration($user));
if (sizeof($register_event_result))
{
foreach ($register_event_result as $result)
{
if ($result instanceof $user_class)
{
$user = $result;
}
}
}
if (!$user->exists)
{
$user->save();
}
\Auth::login($user);
}
else
{
$user->name = $user_info['name'];
$user->email = $user_info['email'];
if ($user instanceof \authkit2\Models\IAuthkitUser)
{
$user->{$user->getAccessTokenName()} = $token->getAccessToken();
$user->{$user->getRefreshTokenName()} = $token->getRefreshToken();
}
else
{
$user->authkit_access_token = $token->getAccessToken();
$user->authkit_refresh_token = $token->getRefreshToken();
}
$user->save();
}
event(new \authkit2\Events\UserLogin($user));
return redirect(url(config('authkit.authn.urls.post_login')));
}
/**
* Explicitly log out of this application and the SSO service
*
* @return mixed
*/
public function logout()
{
event(new \authkit2\Events\UserLogout(\Auth::user()));
// Log out locally
\Auth::logout();
// Redirect to log out remotely as well
return redirect($this->user_flow->getLogoutUrl(url(config('authkit.authn.urls.post_logout'))));
}
}

14
src/Http/Controllers/Controller.php

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace authkit2\Http\Controllers;
use Illuminate\Routing\Controller as BaseController;
/**
* Base controller class
*/
class Controller extends BaseController
{
}

9
src/Models/IAuthkitUser.php

@ -0,0 +1,9 @@
<?php
namespace authkit2\Models;
interface IAuthkitUser
{
public function getAccessTokenName(): string;
public function getRefreshTokenName(): string;
}

29
src/Models/User.php

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace authkit2\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
/**
* Sample user model compatible with authkit
*/
class User extends Authenticatable
{
/** @var string[] */
protected $fillable = [
'name',
'email'
];
/** @var string[] */
protected $hidden = [
'remember_token'
];
public function getAuthIdentifierName()
{
return 'authkit_id';
}
}

34
src/Observers/UserObserver.php

@ -0,0 +1,34 @@
<?php
namespace authkit2\Observers;
class UserObserver
{
/*'retrieved', 'creating', 'created', 'updating', 'updated',
'saving', 'saved', 'restoring', 'restored', 'replicating',
'deleting', 'deleted', 'forceDeleted',*/
public function retrieved($user)
{
if ($user instanceof \authkit2\Models\IAuthkitUser)
{
$token = $user->{$user->getAccessTokenName()};
$refresh = $user->{$user->getRefreshTokenName()};
}
else
{
$token = $user->authkit_access_token;
$refresh = $user->authkit_refresh_token;
}
$user->authkit = \authkit2\Oidc\Token::fromString($token, $refresh);
// TODO: If access_token is expired, try refresh
// If refresh_token is expired, ?!!
// \Illuminate\Auth\Access\UnauthorizedException
}
public function saving($user)
{
unset($user->authkit);
}
}

14
src/Oidc/Flows/UserFlow.php

@ -107,5 +107,19 @@ class UserFlow
return Token::fromResponse($response);
}
/**
* If we want to log out of the SSO service and all apps, the URL to hit to
* sign out everywhere.
*
* @param string $redirect_uri url to redirect back to after logout completes
* @return string
*/
public function getLogoutUrl(string $redirect_uri): string
{
return $this->client->getEndpointUrl('end_session').'?'.http_build_query([
'redirect_uri' => $redirect_uri
]);
}
}

56
src/Oidc/Token.php

@ -11,6 +11,13 @@ use Firebase\JWT\JWK;
*/
class Token
{
/**
* If we check for an expired token and it expires within this many seconds
* from now, just go ahead and refresh it early
* @var int
*/
const EXPIRATION_GRACE_PERIOD = 60;
/**
* Http client we've initialized with our authentication middleware for this
* token in place
@ -60,7 +67,7 @@ class Token
public static function fromString(string $access_token, ?string $refresh_token = null): Token
{
$token = new Token();
$token->importJwt($access_token);
$token->access_token = $access_token;
$token->refresh_token = $refresh_token;
return $token;
}
@ -95,6 +102,15 @@ class Token
return $this->client;
}
/**
* Refresh the access token
*
* @return void
*/
public function refresh()
{
}
/**
* Fetch the raw decoded data out of our JWT access token
*
@ -109,6 +125,30 @@ class Token
return $this->token_data;
}
/**
* Check whether the access token is expired
*
* If we fail to parse it because it's expired, or the expiration time is
* within EXPIRATION_GRACE_PERIOD seconds of now, we consider it expired.
*
* @return bool
*/
public function isExpired(): bool
{
try
{
$data = $this->getTokenData();
}
catch (\Firebase\JWT\ExpiredException $ex)
{
return true;
}
if ($data['exp'] <= time() - static::EXPIRATION_GRACE_PERIOD)
return true;
else
return false;
}
/**
* Decode the access token as a JWT token
*
@ -134,6 +174,16 @@ class Token
return $this->access_token;
}
/**
* Fetch the user's refresh token
*
* @return string
*/
public function getRefreshToken(): string
{
return $this->refresh_token;
}
/**
* Fetch the user info associated with this token from the OIDC
* provider
@ -144,7 +194,7 @@ class Token
{
if (!isset($this->userinfo))
{
$this->userinfo = $this->getClient()->get('userinfo');
$this->userinfo = json_decode(json_encode($this->getClient()->get('userinfo')), true);
}
return $this->userinfo;
@ -167,7 +217,7 @@ class Token
*/
public function getUserId(): string
{
return $this->getTokenData()['sub'];
return 'crn:user:'.$this->getTokenData()['sub'];
}
}

57
src/Providers/Authkit2ServiceProvider.php

@ -0,0 +1,57 @@
<?php
namespace authkit2\Providers;
use Illuminate\Support\ServiceProvider;
class Authkit2ServiceProvider extends ServiceProvider
{
/**
* Register all providers and other components for any enabled features
* of the library.
*
* @return void
*/
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../../config/authkit.php', 'authkit');
if (config('authkit.authn.enable'))
{
$this->app->register(AuthnServiceProvider::class);
}
if (config('authkit.authz.enable'))
{
$this->app->register(AuthzServiceProvider::class);
}
}
/**
* Register publishable resources
*
* @return void
*/
public function boot(): void
{
if ($this->app->runningInConsole())
{
$this->publishes([
__DIR__.'/../../config/authkit.php' => config_path('authkit.php')
], 'config');
$this->publishes([
__DIR__.'/../../database/migrations/new/authkit2_users_update.php' => database_path('migrations/'.date('Y_m_d_His').'_authkit2_users_update.php')
], 'migrations_new');
$this->publishes([
__DIR__.'/../../database/migrations/existing/authkit2_users_update_minimal.php' => database_path('migrations/'.date('Y_m_d_His').'_authkit2_users_update_minimal.php')
], 'migrations_existing');
}
$this->app->booted(function($app) {
\authkit2\Oidc\Client::setUrl(config('authkit.authn.openid.endpoint'));
});
}
}

122
src/Providers/AuthnServiceProvider.php

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace authkit2\Providers;
use Illuminate\Support\ServiceProvider;
/**
* Authentication provider to perform setup for authentication processes
*/
class AuthnServiceProvider extends ServiceProvider
{
/**
* Register the additional service providers the authentication process depends on
*
* @return void
*/
public function register(): void
{
$this->app->singleton(\authkit2\Oidc\Flows\ServiceAccountFlow::class, function($app) {
return new \authkit2\Oidc\Flows\ServiceAccountFlow(config('authkit.authn.openid.client_id'), config('authkit.authn.openid.client_secret'));
});
$this->app->singleton(\authkit2\Oidc\Flows\UserFlow::class, function($app) {
return new \authkit2\Oidc\Flows\UserFlow(config('authkit.authn.openid.client_id'), config('authkit.authn.openid.client_secret'));
});
}
/**
* Initialize and register all authentication resources
*
* @return void
*/
public function boot(): void
{
// register our observer on the user model so we can dynamically add/remove
// the token object + client
$user_model = config('auth.providers.users.model');
$user_model::observe(\authkit2\Observers\UserObserver::class);
// Register all authentication routes
$this->bootRoutes();
// Load keycloak configuration and set in Laravel config()
$this->bootConfig();
}
/**
* Register all authentication routes
* If not already cached, generate and register the URL the SSO service
* should redirect back to.
*
* @return void
*/
protected function bootRoutes(): void
{
// Load routes
\Route::group(config('authkit.authn.routing'), function() {
$this->loadRoutesFrom(__DIR__.'/../../routes/web.php');
});
// Figure out where the route is after booting
if (!config()->has('authkit.authn.openid.redirect_uri'))
{
$this->app->booted(static function() {
// TODO: For some reason route($name) isn't working here. We're
// just looking through the routes manually for now...
foreach (\Route::getRoutes() as $route)
{
if ($route->getName() === 'login.callback')
{
config(['authkit.authn.openid.redirect_uri' => url($route->uri())]);
return;
}
}
throw new \Exception('Route [login.callback] not found');
});
}
}
/**
* Generate any missing config values for keycloak by reading JSON
* auth config
*
* @return void
*/
protected function bootConfig(): void
{
// We check if the values are available because they may have
// previously been generated and cached by Laravel in which case
// we can save some work.
if (!(config()->has('authkit.authn.openid.client_id') &&
config()->has('authkit.authn.openid.client_secret') &&
config()->has('authkit.authn.openid.endpoint')))
{
// Figure out where to load the config from
$disk = config('authkit.authn.config.disk');
$path = config('authkit.authn.config.path');
if (!\Storage::disk($disk)->exists($path))
{
// If it doesn't exist, skip the loading.
$config_json = [];
}
else
{
$config_raw = \Storage::disk($disk)->get($path);
$config_json = json_decode($config_raw, true);
if (!isset($config_json))
{
throw new \Exception("Could not parse authentication configuration at $disk:$path");
}
}
config([
'authkit.authn.openid.client_id' => env('AUTHKIT_CLIENT_ID', $config_json['resource'] ?? null),
'authkit.authn.openid.client_secret' => env('AUTHKIT_CLIENT_SECRET', $config_json['credentials']['secret'] ?? null),
'authkit.authn.openid.endpoint' => env('AUTHKIT_ENDPOINT', isset($config_json['auth-server-url']) && isset($config_json['realm']) ? $config_json['auth-server-url'].'realms/'.$config_json['realm'] : null)
]);
}
}
}

22
src/Providers/AuthzServiceProvider.php

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace authkit2\Providers;
use Illuminate\Support\ServiceProvider;
/**
* Authorization provider to register and configure all
* assets involved in permission checking
*/
class AuthzServiceProvider extends ServiceProvider
{
public function register()
{
}
public function boot()
{
}
}
Loading…
Cancel
Save