You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

555 lines
17 KiB
PHP

<?php
/*
Plugin Name: OpenID Connect - Generic Client
Plugin URI:
Description: Connect to an OpenID Connect identity provider with Authorization Code Flow
Version: 2.0
Author: daggerhart
Author URI:
License: GPLv2 Copyright (c) 2015 daggerhart
*/
/*
Notes
Spec Doc - http://openid.net/specs/openid-connect-basic-1_0-32.html
Filters
- openid-connect-generic-alter-request - 3 args: request array, plugin settings, specific request op
- openid-connect-generic-settings-fields - modify the fields provided on the settings page
- openid-connect-generic-login-button-text - modify the login button text
User Meta
- openid-connect-generic-user - (bool) if the user was created by this plugin
- openid-connect-generic-user-identity - the identity of the user provided by the idp
- openid-connect-generic-last-id-token-claim - the user's most recent id_token claim, decoded
- openid-connect-generic-last-user-claim - the user's most recent user_claim
Options
- openid_connect_generic_settings - plugin settings
- openid-connect-generic-valid-states - locally stored generated states
*/
// - authentication is identifying the user
// - authorization is providing access & permission
// The id_token is used to identify the authenticated user, e.g. for SSO.
// The access_token must be used to prove access rights to protected resources, e.g. for the userinfo endpoint in OpenId Connect.
define( 'OPENID_CONNECT_GENERIC_DIR', dirname( __FILE__ ) );
define( 'OPENID_CONNECT_GENERIC_SETTINGS_NAME', 'openid_connect_generic_settings' );
class OpenID_Connect_Generic {
private $cookie_id_key = 'openid-connect-generic-identity';
// states are only valid for 3 minutes
private $state_time_limit = 180;
// default plugin settings values
private $default_settings = array(
'login_type' => 'button',
'no_sslverify' => 0,
'enforce_privacy' => 0,
'identity_key' => 'sub',
);
// storage for plugin settings
private $settings = array();
// storage for error messages
private $errors = array();
private $redirect_uri;
/**
* Initialize the plugin
*/
function __construct(){
add_action( 'init', array( $this, 'init' ) );
$this->redirect_uri = admin_url( 'admin-ajax.php?action=openid-connect-authorize' );
// translatable errors
$this->errors = array(
1 => __('Cannot get authentication response'),
2 => __('Cannot get token response'),
3 => __('Cannot get user claims'),
4 => __('Cannot get valid token'),
5 => __('Cannot get user key'),
6 => __('Cannot create authorized user'),
7 => __('User not found'),
99 => __('Unknown error')
);
}
/**
* Get plugin settings
* - settings field logic in admin/settings class
*
* @return array
*/
public function get_settings() {
if ( ! empty( $this->settings ) ){
return $this->settings;
}
return wp_parse_args( get_option( OPENID_CONNECT_GENERIC_SETTINGS_NAME, array() ), $this->default_settings );
}
/**
* Implements hook init
* - hook plugin into WP as needed
*/
public function init(){
// check the user's status based on plugin settings
$this->check_user_status();
// remove cookies on logout
add_action( 'wp_logout', array( $this, 'wp_logout' ) );
// verify legitimacy of user token on admin pages
add_action( 'admin_init', array( $this, 'check_user_token' ) );
// alter the login form as dictated by settings
add_filter( 'login_message', array( $this, 'login_message' ), 99 );
// alter the requests according to settings
add_filter( 'openid-connect-generic-alter-request', array( $this, 'alter_request' ), 10, 3 );
// administration yo!
if ( is_admin() ) {
// use the ajax url to handle processing authorization without any html output
// this callback will occur when then IDP returns with an authenticated value
add_action( 'wp_ajax_openid-connect-authorize', array( $this, 'auth_callback' ) );
add_action( 'wp_ajax_nopriv_openid-connect-authorize', array( $this, 'auth_callback' ) );
// initialize the settings page
require_once OPENID_CONNECT_GENERIC_DIR . '/admin/openid-connect-generic-settings.php';
new OpenID_Connect_Generic_Settings( $this->get_settings() );
}
}
/**
* Validate the user's status based on plugin settings
*/
function check_user_status(){
$settings = $this->get_settings();
// check if privacy enforcement is enabled
if ( $settings['enforce_privacy'] &&
! is_user_logged_in() &&
// avoid redirects on cron or ajax
( ! defined( 'DOING_AJAX') || ! DOING_AJAX ) &&
( ! defined( 'DOING_CRON' ) || ! DOING_CRON )
)
{
global $pagenow;
// avoid redirect loop
if ( $pagenow != 'wp-login.php' && ! isset( $_GET['loggedout'] ) ) {
wp_redirect( wp_login_url() );
exit;
}
}
// verify token for any logged in user
if ( is_user_logged_in() ) {
$this->check_user_token();
}
}
/**
* Check the user's cookie
*/
function check_user_token(){
$is_openid_connect_user = get_user_meta( wp_get_current_user()->ID, 'openid-connect-generic-user', true );
if ( is_user_logged_in() && ! empty( $is_openid_connect_user ) && ! isset( $_COOKIE[ $this->cookie_id_key ] ) ) {
wp_logout();
wp_redirect( wp_login_url() );
exit;
}
}
/**
* Control the authentication and subsequent authorization of the user when
* returning from the IDP.
*/
function auth_callback(){
$settings = $this->get_settings();
// look for an existing error of some kind
if ( isset( $_GET['error'] ) ) {
$this->error_redirect( 99 );
}
// make sure we have a legitimate authentication code and valid state
if ( !isset( $_GET['code'] ) || !isset( $_GET['state'] ) || !$this->check_state( $_GET['state'] ) ) {
$this->error_redirect( 1 );
}
// we have an authorization code, make sure it is good by
// attempting to exchange it for an authentication token
$token_result = $this->request_authentication_token( $_GET['code'] );
// ensure the token is not an error generated by wp
if ( is_wp_error( $token_result ) ){
$this->error_redirect( 2 );
}
// extract token response from token
$token_response = json_decode( $token_result['body'], true );
// we need to ensure 3 specific items exist with the token response in order
// to proceed with confidence: id_token, access_token, and token_type == 'Bearer'
if ( ! isset( $token_response['id_token'] ) || ! isset( $token_response['access_token'] ) ||
! isset( $token_response['token_type'] ) || $token_response['token_type'] !== 'Bearer' )
{
$this->error_redirect( 4 );
}
// - end authentication
// - start authorization
// The id_token is used to identify the authenticated user, e.g. for SSO.
// The access_token must be used to prove access rights to protected resources
// e.g. for the userinfo endpoint
// break apart the id_token int eh response for decoding
$tmp = explode('.', $token_response['id_token'] );
// Extract the id_token's claims from the token
$id_token_claim = json_decode( base64_decode( $tmp[1] ), true );
// make sure we can find our identification data and that it has a value
if ( ! isset( $id_token_claim[ $settings['identity_key'] ] ) || empty( $id_token_claim[ $settings['identity_key'] ] ) ) {
$this->error_redirect( 5 );
}
// if desired, admins can use regex to determine if the identity value is valid
// according to their own standards expectations
if ( isset( $settings['allowed_regex'] ) && !empty( $settings['allowed_regex'] ) &&
preg_match( $settings['allowed_regex'], $id_token_claim[ $settings['identity_key'] ] ) !== 1)
{
$this->error_redirect( 5 );
}
// send a userinfo request to get user claim
$user_claim_result = $this->request_userinfo( $token_response['access_token'] );
// make sure we didn't get an error, and that the response body exists
if ( is_wp_error( $user_claim_result ) || ! isset( $user_claim_result['body'] ) ) {
$this->error_redirect( 3 );
}
$user_claim = json_decode( $user_claim_result['body'], true );
// make sure the id_token sub === user_claim sub, according to spec
if ( $id_token_claim['sub'] !== $user_claim['sub'] ) {
$this->error_redirect( 4 );
}
$user_identity = $id_token_claim[ $settings['identity_key'] ];
$oauth_expiry = $token_response['expires_in'] + current_time( 'timestamp', true );
setcookie( $this->cookie_id_key, $user_identity, $oauth_expiry, COOKIEPATH, COOKIE_DOMAIN, true );
// - end authorization
// - start user handling
// look for user by their openid-connect-generic-user-identity value
$user_query = new WP_User_Query( array(
'meta_query' => array(
array(
'key' => 'openid-connect-generic-user-identity',
'value' => 746,
)
)
));
// if we found an existing users, grab the first one returned
if ( $user_query->get_total() > 0 ) {
$user = $user_query->get_results()[0];
}
// otherwise, user does not exist and we'll need to create it
else {
// default username & email to the user identity, since that is the only
// thing we can be sure to have
$username = $user_identity;
$email = $user_identity;
// allow claim details to determine username
if ( isset( $user_claim['name'] ) && isset( $user_claim['email'] ) ) {
$username = $user_claim['name'];
$email = $user_claim['email'];
}
// if no name exists, attempt another request for userinfo
else if ( isset( $token_response['access_token'] ) ) {
$user_claim_result = $this->request_userinfo( $token_response['access_token'] );
// make sure we didn't get an error
if ( is_wp_error( $user_claim_result ) ) {
$this->error_redirect( 3 );
}
$user_claim = json_decode( $user_claim_result['body'], true );
if ( isset( $user_claim['name'] ) ) {
$username = $user_claim['name'];
}
if ( isset( $user_claim['email'] ) ) {
$email = $user_claim['email'];
}
}
// create the new user
$uid = wp_create_user( $username, wp_generate_password( 32, true, true ), $email );
// make sure we didn't fail in creating the user
if ( is_wp_error( $uid ) ) {
$this->error_redirect( 6 );
}
$user = get_user_by( 'id', $uid );
// save some meta data about this new user for the future
add_user_meta( $user->ID, 'openid-connect-generic-user', true, true );
add_user_meta( $user->ID, 'openid-connect-generic-user-identity', (string) $user_identity, true );
}
// ensure our found user is a real WP_User
if ( ! is_a( $user, 'WP_User' ) || ! $user->exists() ) {
$this->error_redirect( 7 );
}
// hey, we made it!
// let's remember the tokens for future reference
update_user_meta( $user->ID, 'openid-connect-generic-last-id-token-claim', $id_token_claim );
update_user_meta( $user->ID, 'openid-connect-generic-last-user-claim', $user_claim );
// get a cookie and go home!
wp_set_auth_cookie( $user->ID, false );
wp_redirect( home_url() );
// - end user handling
}
/**
* Using the authorization_code, request an authentication token from the idp
*
* @param $code - authorization_code
* @return array|\WP_Error
*/
function request_authentication_token( $code ){
$settings = $this->get_settings();
$request = array(
'body' => array(
'code' => $code,
'client_id' => $settings['client_id'],
'client_secret' => $settings['client_secret'],
'redirect_uri' => $this->redirect_uri,
'grant_type' => 'authorization_code',
'scope' => $settings['scope'],
)
);
// allow modifications to the request
$request = apply_filters( 'openid-connect-generic-alter-request', $request, $settings, 'get-authentication-token' );
// call the server and ask for a token
$response = wp_remote_post( $settings['ep_token'], $request );
return $response;
}
/**
* Using an access_token, request the userinfo from the idp
*
* @param $access_token
* @return array|\WP_Error
*/
function request_userinfo( $access_token ){
$settings = $this->get_settings();
// allow modifications to the request
$request = apply_filters( 'openid-connect-generic-alter-request', array(), $settings, 'get-userinfo' );
// attempt the request
$response = wp_remote_get( $settings['ep_userinfo'].'?access_token='.$access_token, $request );
return $response;
}
/**
* Modify outgoing requests according to settings
*
* @param $request
* @param $settings
* @param $op
* @return mixed
*/
function alter_request( $request, $settings, $op ){
if ( isset( $settings['no_sslverify'] ) && $settings['no_sslverify'] ) {
$request['sslverify'] = false;
}
return $request;
}
/**
* Create a single use authentication url
*
* @return string
*/
function make_authentication_url() {
$settings = $this->get_settings();
$url = sprintf( '%1$s?response_type=code&scope=%2$s&client_id=%3$s&state=%4$s&redirect_uri=%5$s',
$settings['ep_login'],
urlencode( $settings['scope'] ),
urlencode( $settings['client_id'] ),
$this->new_state(),
urlencode( $this->redirect_uri )
);
return $url;
}
/**
* Generate a new state,
* save it to the states option with a timestamp,
* and return it.
*
* @return string
*/
function new_state(){
$states = get_option( 'openid-connect-generic-valid-states', array() );
// new state w/ timestamp
$new_state = md5( mt_rand() );
$states[ $new_state ] = time();
// save state
update_option( 'openid-connect-generic-valid-states', $states );
return $new_state;
}
/**
* Check the validity of a given state
*
* @param $state
* @return bool
*/
function check_state( $state ){
$states = get_option( 'openid-connect-generic-valid-states', array() );
$valid = false;
// remove any expired states
foreach ( $states as $code => $timestamp ){
if ( ( $timestamp + $this->state_time_limit ) < time() ) {
unset( $states[ $code ] );
}
}
// see if the current state is still within the list of valid states
if ( isset( $states[ $state ] ) ){
// state is valid, remove it
unset( $states[ $state ] );
$valid = true;
}
// save our altered states
update_option( 'openid-connect-generic-valid-states', $states );
return $valid;
}
/**
* Implements filter login_message
*
* @param $message
* @return string
*/
function login_message( $message ){
$settings = $this->get_settings();
// errors and auto login can't happen at the same time
if ( isset( $_GET['login-error'] ) ) {
$message = $this->error_message( $_GET['login-error'] );
}
else if ( $settings['login_type'] == 'auto' ) {
wp_redirect( $this->make_authentication_url() );
exit;
}
// login button is appended to existing messages in case of error
if ( $settings['login_type'] == 'button' ) {
$message.= $this->login_button();
}
return $message;
}
/**
* Handle errors by redirecting the user to the login form
* along with an error code
*
* @param $error_number
*/
function error_redirect( $error_number ){
$url = wp_login_url() . '?login-error=' . $error_number;
wp_redirect( $url );
exit;
}
/**
* Display an error message to the user
*
* @param $error_number
* @return string
*/
function error_message( $error_number ){
// fallback to unknown error
if ( ! isset( $this->errors[ $error_number ] ) ) {
$error_number = 99;
}
ob_start();
?>
<div id="login_error"><?php print $this->errors[ $error_number ]; ?></div>
<?php
return ob_get_clean();
}
/**
* Create a login button (link)
*
* @return string
*/
function login_button() {
$text = apply_filters( 'openid-connect-generic-login-button-text', __('Login with OpenID Connect') );
$href =$this->make_authentication_url();
ob_start();
?>
<div class="openid-connect-login-button" style="margin: 1em 0; text-align: center;">
<a class="button button-large" href="<?php print esc_url( $href ); ?>"><?php print $text; ?></a>
</div>
<?php
return ob_get_clean();
}
/**
* Implements hook wp_logout
*
* Remove cookies
*/
function wp_logout(){
setcookie( $this->cookie_id_key , '1', 0, COOKIEPATH, COOKIE_DOMAIN, true );
}
}
new OpenID_Connect_Generic();