From 280b518d27921101e7251740d6692bcacea5037c Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 31 Oct 2020 10:40:45 +0100 Subject: [PATCH] Implement support for OAuth2 flow, including scope consent * [x] OAuth2 type "request_token" * [x] Ask for user consent for scopes * [x] Ask for consent if additional scopes requested * [ ] Client persistence and admin commands * [ ] Identity API extensibility * [ ] Test coverage --- src/main/handlebars/scopes.handlebars | 51 +++++++++++++++++++ src/main/php/de/thekid/cas/App.php | 18 ++++--- .../php/de/thekid/cas/Implementations.php | 3 ++ src/main/php/de/thekid/cas/Tokens.php | 41 +++++++++++++++ src/main/php/de/thekid/cas/flow/Authorize.php | 48 +++++++++++++++++ .../thekid/cas/flow/ContinueAuthorization.php | 27 ++++++++++ .../php/de/thekid/cas/flow/SelectScopes.php | 40 +++++++++++++++ .../php/de/thekid/cas/impl/AccessToken.php | 35 +++++++++++++ .../{AuthenticationFlow.php => CasFlow.php} | 13 +++-- src/main/php/de/thekid/cas/impl/Identity.php | 33 ++++++++++++ src/main/php/de/thekid/cas/impl/Login.php | 10 +++- src/main/php/de/thekid/cas/impl/OAuthFlow.php | 18 +++++++ .../php/de/thekid/cas/unittest/LoginTest.php | 2 +- 13 files changed, 321 insertions(+), 18 deletions(-) create mode 100755 src/main/handlebars/scopes.handlebars create mode 100755 src/main/php/de/thekid/cas/Tokens.php create mode 100755 src/main/php/de/thekid/cas/flow/Authorize.php create mode 100755 src/main/php/de/thekid/cas/flow/ContinueAuthorization.php create mode 100755 src/main/php/de/thekid/cas/flow/SelectScopes.php create mode 100755 src/main/php/de/thekid/cas/impl/AccessToken.php rename src/main/php/de/thekid/cas/impl/{AuthenticationFlow.php => CasFlow.php} (60%) create mode 100755 src/main/php/de/thekid/cas/impl/Identity.php create mode 100755 src/main/php/de/thekid/cas/impl/OAuthFlow.php diff --git a/src/main/handlebars/scopes.handlebars b/src/main/handlebars/scopes.handlebars new file mode 100755 index 0000000..7f70b21 --- /dev/null +++ b/src/main/handlebars/scopes.handlebars @@ -0,0 +1,51 @@ +{{#> layout}} + {{#*inline "title"}}Scopes{{/inline}} + {{#*inline "content"}} +

Scopes

+ +
+ + + + {{#if error.failed}} +
Please select at least one scope.
+ {{/if}} + + {{#if granted}} +

{{app}} would like additional permissions:

+ {{else}} +

{{app}} would like to:

+ {{/if}} + + {{#each granted}} +
+
+ + +
+
+ {{/each}} + {{#each delta}} +
+
+ + +
+
+ {{/each}} + + +
+ {{#if service}} +
You are logging in for {{service}}
+ {{/if}} + {{/inline}} + {{#*inline "script"}} + + {{/inline}} +{{/layout}} \ No newline at end of file diff --git a/src/main/php/de/thekid/cas/App.php b/src/main/php/de/thekid/cas/App.php index 6f234e1..3845cef 100755 --- a/src/main/php/de/thekid/cas/App.php +++ b/src/main/php/de/thekid/cas/App.php @@ -1,6 +1,6 @@ expanding($this->environment->properties('inject'))), new Implementations(), new Frontend($webroot, 'dev' === $this->environment->profile()), - new AuthenticationFlow(), ); return [ - '/favicon.ico' => $files, - '/static' => $files, - '/serviceValidate' => $inject->get(Validate::class), - '/login' => $inject->get(Login::class), - '/logout' => $inject->get(Logout::class), - '/' => fn($req, $res) => { + '/favicon.ico' => $files, + '/static' => $files, + '/serviceValidate' => $inject->get(Validate::class), + '/login' => $inject->get(Login::class)->using($inject->get(CasFlow::class)), + '/oauth/authorize' => $inject->get(Login::class)->using($inject->get(OAuthFlow::class)), + '/oauth/access_token' => $inject->get(AccessToken::class), + '/oauth/identity' => $inject->get(Identity::class), + '/logout' => $inject->get(Logout::class), + '/' => fn($req, $res) => { $res->answer(301); $res->header('Location', $req->uri()->using()->path('/login')->params($req->params())->create()); } diff --git a/src/main/php/de/thekid/cas/Implementations.php b/src/main/php/de/thekid/cas/Implementations.php index 6c6e7ea..04308ee 100755 --- a/src/main/php/de/thekid/cas/Implementations.php +++ b/src/main/php/de/thekid/cas/Implementations.php @@ -4,7 +4,9 @@ use de\thekid\cas\tickets\{Tickets, TicketDatabase}; use de\thekid\cas\users\{Users, UserDatabase}; use inject\Bindings; +use lang\Environment; use rdbms\DriverManager; +use web\session\InFileSystem; /** Default implementations for services, users and tickets */ class Implementations extends Bindings { @@ -19,5 +21,6 @@ public function configure($inject) { $inject->bind(Services::class, new AllowMatching($inject->get('string', 'services'))); $inject->bind(Users::class, new UserDatabase($conn)); $inject->bind(Tickets::class, new TicketDatabase($conn)); + $inject->bind(Tokens::class, new Tokens(new InFileSystem(Environment::tempDir()))); } } \ No newline at end of file diff --git a/src/main/php/de/thekid/cas/Tokens.php b/src/main/php/de/thekid/cas/Tokens.php new file mode 100755 index 0000000..12e57b0 --- /dev/null +++ b/src/main/php/de/thekid/cas/Tokens.php @@ -0,0 +1,41 @@ +sessions->create(); + + try { + $session->register('token', ['value' => $value]); + return $session->id(); + } finally { + $session->close(); + } + } + + /** + * Resolve a token and return the value (or NULL if the token does not exist) + * + * @param string $token + * @return var + */ + public function resolve($token) { + if (null === ($session= $this->sessions->open($token))) return null; + + try { + return $session->value('token')['value'] ?? null; + } finally { + $session->close(); + } + } +} \ No newline at end of file diff --git a/src/main/php/de/thekid/cas/flow/Authorize.php b/src/main/php/de/thekid/cas/flow/Authorize.php new file mode 100755 index 0000000..dbe2689 --- /dev/null +++ b/src/main/php/de/thekid/cas/flow/Authorize.php @@ -0,0 +1,48 @@ +clients= new class() { + public function lookup($id) { + return [ + 'id' => $id, + 'name' => 'Auth @ localhost', + 'uris' => ['http://oauth.example.com/'], + 'secret' => 'e7911968cbb49487ec3a249c6aee3fbaa0c2fe90' + ]; + } + }; + } + + public function setup($req, $res, $session) { + if ('code' !== $req->param('response_type')) { + return new View('forbidden'); + } + + if (null === ($client= $this->clients->lookup($req->param('client_id')))) { + return new View('forbidden'); + } + + // Default to "user" scope + $scopes= []; + foreach (explode(' ', $req->param('scope') ?? 'user') as $scope) { + $scopes[$scope]= true; + } + $session->register('oauth', [ + 'client' => $client, + 'state' => $req->param('state'), + 'scopes' => $scopes + ]); + $session->register('service', $req->param('redirect_uri') ?? $client['uris'][0]); + return null; + } + + public function complete($req, $res, $session) { + // NOOP + } +} \ No newline at end of file diff --git a/src/main/php/de/thekid/cas/flow/ContinueAuthorization.php b/src/main/php/de/thekid/cas/flow/ContinueAuthorization.php new file mode 100755 index 0000000..87c19ed --- /dev/null +++ b/src/main/php/de/thekid/cas/flow/ContinueAuthorization.php @@ -0,0 +1,27 @@ +value('oauth') ?? throw new IllegalStateException('Missing authorization'); + $id= $this->tickets->create($oauth + ['granted' => $session->value('scopes'), 'user' => $session->value('user')]); + + return new Redirect(new URI($session->value('service')) + ->using() + ->param('state', $oauth['state']) + ->param('code', $this->signed->id($id, $this->tickets->prefix())) + ->create() + ); + } + + public function complete($req, $res, $session) { + // Never called, unconditionally redirects + } +} \ No newline at end of file diff --git a/src/main/php/de/thekid/cas/flow/SelectScopes.php b/src/main/php/de/thekid/cas/flow/SelectScopes.php new file mode 100755 index 0000000..6fbdb44 --- /dev/null +++ b/src/main/php/de/thekid/cas/flow/SelectScopes.php @@ -0,0 +1,40 @@ +value('oauth'); + $granted= $session->value('scopes') ?? []; + + // If additional scopes are requested, re-show consent screen + if ($delta= array_diff_key($auth['scopes'], $granted)) { + return new View('scopes', [ + 'app' => $auth['client']['name'], + 'scopes' => $auth['scopes'], + 'service' => $session->value('service'), + 'granted' => $granted, + 'delta' => $delta, + ]); + } + + return null; + } + + public function complete($req, $res, $session) { + $scopes= $req->param('scopes'); + if (empty($scopes)) return ['failed' => 'no-scopes']; + + // Store user selection in session + $granted= []; + foreach ($session->value('oauth')['scopes'] as $scope => $_) { + $granted[$scope]= isset($scopes[$scope]); + } + $session->register('scopes', $granted); + return null; + } +} \ No newline at end of file diff --git a/src/main/php/de/thekid/cas/impl/AccessToken.php b/src/main/php/de/thekid/cas/impl/AccessToken.php new file mode 100755 index 0000000..b4003c5 --- /dev/null +++ b/src/main/php/de/thekid/cas/impl/AccessToken.php @@ -0,0 +1,35 @@ +signed->verify($req->param('code'), $this->tickets->prefix()); + if (null === $id || null === ($ticket= $this->tickets->validate($id))) { + throw new Error(401, 'Invalid code '.$req->param('code')); + } + + if ($ticket['state'] !== $req->param('state')) { + throw new Error(401, 'Invalid state '.$req->param('state')); + } + + $client= $ticket['client']; + if ($client['id'] !== $req->param('client_id') || $client['secret'] !== $req->param('client_secret')) { + throw new Error(401, 'Invalid client '.$req->param('client_id')); + } + + // Issue and send token + $token= $this->tokens->issue($ticket); + $res->answer(200); + $res->send('access_token='.urlencode($token).'&token_type=Bearer', 'application/x-www-form-urlencoded'); + } +} \ No newline at end of file diff --git a/src/main/php/de/thekid/cas/impl/AuthenticationFlow.php b/src/main/php/de/thekid/cas/impl/CasFlow.php similarity index 60% rename from src/main/php/de/thekid/cas/impl/AuthenticationFlow.php rename to src/main/php/de/thekid/cas/impl/CasFlow.php index fd3a740..c5d3a8a 100755 --- a/src/main/php/de/thekid/cas/impl/AuthenticationFlow.php +++ b/src/main/php/de/thekid/cas/impl/CasFlow.php @@ -1,19 +1,18 @@ bind(Flow::class, new Flow([ + public function __construct(Injector $inject) { + parent::__construct([ $inject->get(UseService::class), $inject->get(EnterCredentials::class), $inject->get(QueryMFACode::class), $inject->get(RedirectToService::class), $inject->get(DisplaySuccess::class), - ])); + ]); } } \ No newline at end of file diff --git a/src/main/php/de/thekid/cas/impl/Identity.php b/src/main/php/de/thekid/cas/impl/Identity.php new file mode 100755 index 0000000..dc6fcce --- /dev/null +++ b/src/main/php/de/thekid/cas/impl/Identity.php @@ -0,0 +1,33 @@ +header('Authorization'), "Bearer %[^\r]", $token); + if (null === $token) { + throw new Error(401, 'Missing access token'); + } + + if (null === ($access= $this->tokens->resolve($token))) { + throw new Error(403, 'Invalid access token '.$token); + } + + if (!isset($access['granted']['user'])) { + throw new Error(403, 'Cannot access user scope with token '.$token); + } + + $identity= [ + 'username' => $access['user']['username'], + 'access' => array_keys(array_filter($access['granted'])), + ...(array)$access['user']['attributes'] + ]; + $res->answer(200); + $res->send(json_encode($identity), 'application/json'); + } +} \ No newline at end of file diff --git a/src/main/php/de/thekid/cas/impl/Login.php b/src/main/php/de/thekid/cas/impl/Login.php index 29142ed..df594a6 100755 --- a/src/main/php/de/thekid/cas/impl/Login.php +++ b/src/main/php/de/thekid/cas/impl/Login.php @@ -13,15 +13,21 @@ */ class Login implements Handler { private const RAND = 8; + private $flow; /** Creates a new login page flow */ public function __construct( private Templating $templates, - private Flow $flow, private Sessions $sessions, - private Signed $signed, + private Signed $signed ) { } + /** Sets flow instance */ + public function using(Flow $flow): self { + $this->flow= $flow; + return $this; + } + /** @return var */ public function handle($req, $res) { diff --git a/src/main/php/de/thekid/cas/impl/OAuthFlow.php b/src/main/php/de/thekid/cas/impl/OAuthFlow.php new file mode 100755 index 0000000..c2d0acf --- /dev/null +++ b/src/main/php/de/thekid/cas/impl/OAuthFlow.php @@ -0,0 +1,18 @@ +get(Authorize::class), + $inject->get(EnterCredentials::class), + $inject->get(QueryMFACode::class), + $inject->get(SelectScopes::class), + $inject->get(ContinueAuthorization::class) + ]); + } +} \ No newline at end of file diff --git a/src/test/php/de/thekid/cas/unittest/LoginTest.php b/src/test/php/de/thekid/cas/unittest/LoginTest.php index 280d5d7..50d1a85 100755 --- a/src/test/php/de/thekid/cas/unittest/LoginTest.php +++ b/src/test/php/de/thekid/cas/unittest/LoginTest.php @@ -30,7 +30,7 @@ public function initialize() { /** @return web.Handler */ - protected fn handler() => new Login($this->templates, $this->flow, $this->sessions, $this->signed); + protected fn handler() => new Login($this->templates, $this->sessions, $this->signed)->using($this->flow); #[Test] public function creates_session_if_ncessary() {