436 lines
15 KiB
PHP
436 lines
15 KiB
PHP
<?php
|
|
|
|
/**
|
|
* kolab_sso driver implementing OAuth2 Authorization (RFC6749)
|
|
* with use of JWT tokens.
|
|
*
|
|
* @author Aleksander Machniak <machniak@kolabsys.com>
|
|
*
|
|
* Copyright (C) 2018-2019, Kolab Systems AG <contact@kolabsys.com>
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as
|
|
* published by the Free Software Foundation, either version 3 of the
|
|
* License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
class kolab_sso_oauth2
|
|
{
|
|
protected $plugin;
|
|
protected $id = 'oauth2';
|
|
protected $config = array();
|
|
protected $params = array();
|
|
protected $defaults = array(
|
|
'scope' => 'email',
|
|
'token_type' => 'access_token',
|
|
'user_field' => 'email',
|
|
'validate_items' => array('aud'),
|
|
);
|
|
|
|
|
|
/**
|
|
* Object constructor
|
|
*
|
|
* @param rcube_plugin $plugin kolab_sso plugin object
|
|
* @param array $config Driver configuration
|
|
*/
|
|
public function __construct($plugin, $config)
|
|
{
|
|
$this->plugin = $plugin;
|
|
$this->config = $config;
|
|
|
|
$this->plugin->require_plugin('libkolab');
|
|
}
|
|
|
|
/**
|
|
* Authentication request (redirect to SSO service)
|
|
*/
|
|
public function authorize()
|
|
{
|
|
$params = array(
|
|
'response_type' => 'code',
|
|
'scope' => $this->get_param('scope'),
|
|
'client_id' => $this->get_param('client_id'),
|
|
'state' => $this->plugin->rc->get_request_token(),
|
|
'redirect_uri' => $this->redirect_uri(),
|
|
);
|
|
|
|
// Add extra request parameters (don't overwrite params set above)
|
|
if (!empty($this->config['extra_params'])) {
|
|
$params = array_merge((array) $this->config['extra_params'], $params);
|
|
}
|
|
|
|
$url = $this->config['auth_uri'] ?: (unslashify($this->config['uri']) . '/authorize');
|
|
$url .= '?' . http_build_query($params);
|
|
|
|
$this->plugin->debug("[{$this->id}][authorize] Redirecting to $url");
|
|
|
|
header("Location: $url");
|
|
die;
|
|
}
|
|
|
|
/**
|
|
* Authorization response validation
|
|
*/
|
|
public function response()
|
|
{
|
|
$this->plugin->debug("[{$this->id}][authorize] Response: " . $_SERVER['REQUEST_URI']);
|
|
|
|
$this->error = $this->error_message(
|
|
rcube_utils::get_input_value('error', rcube_utils::INPUT_GET),
|
|
rcube_utils::get_input_value('error_description', rcube_utils::INPUT_GET),
|
|
rcube_utils::get_input_value('error_uri', rcube_utils::INPUT_GET)
|
|
);
|
|
|
|
if ($this->error) {
|
|
return;
|
|
}
|
|
|
|
$state = rcube_utils::get_input_value('state', rcube_utils::INPUT_GET);
|
|
$code = rcube_utils::get_input_value('code', rcube_utils::INPUT_GET);
|
|
|
|
if (!$state) {
|
|
$this->plugin->debug("[{$this->id}][response] State missing");
|
|
$this->error = $this->plugin->gettext('errorinvalidresponse');
|
|
return;
|
|
}
|
|
|
|
if ($state != $this->plugin->rc->get_request_token()) {
|
|
$this->plugin->debug("[{$this->id}][response] Invalid response state");
|
|
$this->error = $this->plugin->gettext('errorinvalidresponse');
|
|
return;
|
|
}
|
|
|
|
if (!$code) {
|
|
$this->plugin->debug("[{$this->id}][response] Code missing");
|
|
$this->error = $this->plugin->gettext('errorinvalidresponse');
|
|
return;
|
|
}
|
|
|
|
return $this->request_token($code);
|
|
}
|
|
|
|
/**
|
|
* Error message for the response handler
|
|
*/
|
|
public function response_error()
|
|
{
|
|
if ($this->error) {
|
|
return $this->plugin->rc->gettext('loginfailed') . ' ' . $this->error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Existing session validation
|
|
*/
|
|
public function validate_session($session)
|
|
{
|
|
$this->plugin->debug("[{$this->id}][validate] Session: " . json_encode($session));
|
|
|
|
// Sanity checks
|
|
if (empty($session) || empty($session['code']) || empty($session['validto']) || empty($session['email'])) {
|
|
$this->plugin->debug("[{$this->id}][validate] Session invalid");
|
|
return;
|
|
}
|
|
|
|
// Check expiration time
|
|
$now = new DateTime('now', new DateTimezone('UTC'));
|
|
$validto = new DateTime($session['validto'], new DateTimezone('UTC'));
|
|
|
|
// Don't refresh often than TTL/2
|
|
$validto->sub(new DateInterval(sprintf('PT%dS', $session['ttl']/2)));
|
|
if ($now < $validto) {
|
|
$this->plugin->debug("[{$this->id}][validate] Token valid, skipping refresh");
|
|
return $session;
|
|
}
|
|
|
|
// No refresh_token, not possible to refresh
|
|
if (empty($session['refresh_token'])) {
|
|
$this->plugin->debug("[{$this->id}][validate] Session cannot be refreshed");
|
|
return;
|
|
}
|
|
|
|
// Renew tokens
|
|
$info = $this->request_token($session['code'], $session['refresh_token']);
|
|
|
|
if (!empty($info)) {
|
|
// Make sure the email didn't change
|
|
if (!empty($info['email']) && $info['email'] != $session['email']) {
|
|
$this->plugin->debug("[{$this->id}][validate] Email address change");
|
|
return;
|
|
}
|
|
|
|
$session = array_merge($session, $info);
|
|
|
|
$this->plugin->debug("[{$this->id}][validate] Session valid: " . json_encode($session));
|
|
return $session;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Authentication Token request (or token refresh)
|
|
*/
|
|
protected function request_token($code, $refresh_token = null)
|
|
{
|
|
$mode = $refresh_token ? 'token-refresh' : 'token';
|
|
$url = $this->config['token_uri'] ?: ($this->config['uri'] . '/token');
|
|
$params = array(
|
|
'client_id' => $this->get_param('client_id'),
|
|
'client_secret' => $this->get_param('client_secret'),
|
|
'grant_type' => $refresh_token ? 'refresh_token' : 'authorization_code',
|
|
);
|
|
|
|
if ($refresh_token) {
|
|
$params['refresh_token'] = $refresh_token;
|
|
$params['scope'] = $this->get_param('scope');
|
|
}
|
|
else {
|
|
$params['code'] = $code;
|
|
$params['redirect_uri'] = $this->redirect_uri();
|
|
}
|
|
|
|
// Add extra request parameters (don't overwrite params set above)
|
|
if (!empty($this->config['extra_params'])) {
|
|
$params = array_merge((array) $this->config['extra_params'], $params);
|
|
}
|
|
|
|
$post = http_build_query($params);
|
|
|
|
$this->plugin->debug("[{$this->id}][$mode] Requesting POST $url?$post");
|
|
|
|
try {
|
|
// TODO: JWT-based methods of client authentication
|
|
// https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.9
|
|
|
|
$request = $this->get_request($url, 'POST');
|
|
$request->setAuth($params['client_id'], $params['client_secret']);
|
|
$request->setBody($post);
|
|
|
|
$response = $request->send();
|
|
$status = $response->getStatus();
|
|
$response = $response->getBody();
|
|
|
|
$this->plugin->debug("[{$this->id}][$mode] Response: $response");
|
|
|
|
$response = @json_decode($response, true);
|
|
|
|
if ($status != 200 || !is_array($response) || !empty($response['error'])) {
|
|
$err = $this->error_text(is_array($response) ? $response['error'] : null);
|
|
throw new Exception("OpenIDC request failed with error: $err");
|
|
}
|
|
}
|
|
catch (Exception $e) {
|
|
$this->error = $this->plugin->gettext('errorunknown');
|
|
rcube::raise_error(array(
|
|
'line' => __LINE__, 'file' => __FILE__, 'message' => $e->getMessage()),
|
|
true, false);
|
|
return;
|
|
}
|
|
|
|
// Example response: {
|
|
// "access_token":"ACCESS_TOKEN",
|
|
// "token_type":"bearer",
|
|
// "expires_in":2592000,
|
|
// "refresh_token":"REFRESH_TOKEN",
|
|
// "scope":"read",
|
|
// "uid":100101,
|
|
// "info":{"name":"Mark E. Mark","email":"mark@example.com"}
|
|
// }
|
|
|
|
if (empty($response['access_token']) || empty($response['token_type'])
|
|
|| strtolower($response['token_type']) != 'bearer'
|
|
) {
|
|
$this->error = $this->plugin->gettext('errorinvalidresponse');
|
|
$this->plugin->debug("[{$this->id}][$mode] Error: Invalid or unsupported response");
|
|
return;
|
|
}
|
|
|
|
$ttl = $response['expires_in'] ?: 600;
|
|
$validto = new DateTime(sprintf('+%d seconds', $ttl), new DateTimezone('UTC'));
|
|
$token = $response[$this->get_param('token_type')];
|
|
|
|
$result = array(
|
|
'code' => $code,
|
|
'access_token' => $response['access_token'],
|
|
// 'token_type' => $response['token_type'],
|
|
'validto' => $validto->format(DateTime::ISO8601),
|
|
'ttl' => $ttl,
|
|
);
|
|
|
|
if (!empty($response['refresh_token'])) {
|
|
$result['refresh_token'] = $response['refresh_token'];
|
|
}
|
|
|
|
if (!empty($token)) {
|
|
try {
|
|
$key = $params['client_secret'];
|
|
|
|
if (!empty($this->config['pubkey'])) {
|
|
$pubkey = trim(preg_replace('/\r?\n[\s\t]+/', "\n", $this->config['pubkey']));
|
|
|
|
if (strpos($pubkey, '-----') !== 0) {
|
|
$pubkey = "-----BEGIN PUBLIC KEY-----\n" . trim(chunk_split($pubkey, 64, "\n")) . "\n-----END PUBLIC KEY-----";
|
|
}
|
|
|
|
if ($keyid = openssl_pkey_get_public($pubkey)) {
|
|
$key = $keyid;
|
|
}
|
|
else {
|
|
throw new Exception("Failed to extract public key");
|
|
}
|
|
}
|
|
|
|
$jwt = new Firebase\JWT\JWT;
|
|
$jwt::$leeway = 60;
|
|
|
|
$payload = $jwt->decode($token, $key, array_keys(Firebase\JWT\JWT::$supported_algs));
|
|
|
|
$result['email'] = $this->validate_token_payload($payload);
|
|
}
|
|
catch (Exception $e) {
|
|
$this->error = $this->plugin->gettext('errorinvalidtoken');
|
|
rcube::raise_error(array(
|
|
'line' => __LINE__, 'file' => __FILE__, 'message' => $e->getMessage()),
|
|
true, false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Validates JWT token payload and returns user/email
|
|
*/
|
|
protected function validate_token_payload($payload)
|
|
{
|
|
$items = $this->get_maram('validate_items');
|
|
$email = $this->config['debug_email'] ?: $payload->{$this->get_param('user_field')};
|
|
|
|
if (empty($email)) {
|
|
throw new Exception("No email address in JWT token");
|
|
}
|
|
|
|
foreach ((array) $items as $item_name) {
|
|
// More extended token validation
|
|
// https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
|
|
switch (strtolower($item_name)) {
|
|
case 'aud':
|
|
if (!in_array($this->get_param('client_id'), (array) $payload->aud)) {
|
|
throw new Exception("Token audience does not match");
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $email;
|
|
}
|
|
|
|
/**
|
|
* The URL to use when redirecting the user from SSO back to Roundcube
|
|
*/
|
|
protected function redirect_uri()
|
|
{
|
|
// response_uri is useful when the Provider does not allow
|
|
// URIs with parameters. In such case set response_uri = '/sso'
|
|
// and define a redirect in http server, example for Apache:
|
|
// RewriteRule "^sso" "/roundcubemail/?_task=login&_action=sso" [L,QSA]
|
|
|
|
$redirect_params = empty($this->config['response_uri']) ? array('_action' => 'sso') : array();
|
|
|
|
$url = $this->plugin->rc->url($redirect_params, false, true);
|
|
|
|
if (!empty($this->config['response_uri'])) {
|
|
$url = unslashify(preg_replace('/\?.*$/', '', $url)) . '/' . ltrim($this->config['response_uri'], '/');
|
|
}
|
|
|
|
return $url;
|
|
}
|
|
|
|
/**
|
|
* Get HTTP/Request2 object
|
|
*/
|
|
protected function get_request($url, $type)
|
|
{
|
|
$config = array_intersect_key($this->config, array_flip(array(
|
|
'ssl_verify_peer',
|
|
'ssl_verify_host',
|
|
'ssl_cafile',
|
|
'ssl_capath',
|
|
'ssl_local_cert',
|
|
'ssl_passphrase',
|
|
'follow_redirects',
|
|
)));
|
|
|
|
return libkolab::http_request($url, $type, $config);
|
|
}
|
|
|
|
/**
|
|
* Returns (localized) user-friendly error message
|
|
*/
|
|
protected function error_message($error, $description, $uri)
|
|
{
|
|
if (empty($error)) {
|
|
return;
|
|
}
|
|
|
|
$msg = $this->error_text($error);
|
|
|
|
rcube::raise_error(array(
|
|
'message' => "[SSO] $msg." . ($description ? " $description" : '') . ($uri ? " ($uri)" : '')
|
|
), true, false);
|
|
|
|
$label = 'error' . str_replace('_', '', $error);
|
|
if (!$this->plugin->rc->text_exists($label, 'kolab_sso')) {
|
|
$label = 'errorunknown';
|
|
}
|
|
|
|
return $this->plugin->gettext($label);
|
|
}
|
|
|
|
/**
|
|
* Returns error text for specified OpenIDC error code
|
|
*/
|
|
protected function error_text($error)
|
|
{
|
|
switch ($error) {
|
|
case 'invalid_request':
|
|
return "Request malformed";
|
|
case 'unauthorized_client':
|
|
return "The client is not authorized";
|
|
case 'invalid_client':
|
|
return "Client authentication failed";
|
|
case 'access_denied':
|
|
return "Request denied";
|
|
case 'unsupported_response_type':
|
|
return "Unsupported response type";
|
|
case 'invalid_grant':
|
|
return "Invalid authorization grant";
|
|
case 'unsupported_grant_type':
|
|
return "Unsupported authorization grant";
|
|
case 'invalid_scope':
|
|
return "Invalid scope";
|
|
case 'server_error':
|
|
return "Server error";
|
|
case 'temporarily_unavailable':
|
|
return "Service temporarily unavailable";
|
|
}
|
|
|
|
return "Unknown error";
|
|
}
|
|
|
|
/**
|
|
* Returns (hardcoded/configured/default) value of a configuration param
|
|
*/
|
|
protected function get_param($name)
|
|
{
|
|
return $this->params[$name] ?: ($this->config[$name] ?: $this->defaults[$name]);
|
|
}
|
|
}
|