2019-09-10 00:38:27 +00:00
< ? php
/** @ license MIT
* Copyright 2017 J . King , Dustin Wilson et al .
* See LICENSE and AUTHORS files for details */
declare ( strict_types = 1 );
namespace JKingWeb\Arsse\REST\Microsub ;
2019-09-10 04:02:11 +00:00
use JKingWeb\Arsse\Arsse ;
2019-09-10 00:38:27 +00:00
use JKingWeb\Arsse\Misc\URL ;
2019-09-10 21:48:38 +00:00
use JKingWeb\Arsse\Misc\Date ;
2019-09-10 00:38:27 +00:00
use Psr\Http\Message\ServerRequestInterface ;
use Psr\Http\Message\ResponseInterface ;
2019-09-14 22:44:40 +00:00
use Zend\Diactoros\Response\HtmlResponse ;
use Zend\Diactoros\Response\JsonResponse ;
2019-09-10 00:38:27 +00:00
use Zend\Diactoros\Response\EmptyResponse ;
class Auth extends \JKingWeb\Arsse\REST\AbstractHandler {
2019-09-13 15:02:56 +00:00
/** The scopes which we grant to Microsub clients. Mute and block are not included because they have no meaning in an RSS/Atom context; this may signal to clients to suppress muting and blocking in their UI */
const SCOPES = " read follow channels " ;
2019-09-19 23:38:54 +00:00
/** The list of the logical functions of this API, with their implementations */
2019-09-14 22:44:40 +00:00
const FUNCTIONS = [
'discovery' => [ 'GET' => " opDiscovery " ],
2019-09-19 23:38:54 +00:00
'auth' => [ 'GET' => " opLogin " , 'POST' => " opCodeVerification " ],
'token' => [ 'GET' => " opTokenVerification " , 'POST' => " opIssueAccessToken " ],
];
/** The minimal set of reserved URL characters which must be escaped when comparing user ID URLs */
const USERNAME_ESCAPES = [
'#' => " %23 " ,
'%' => " %25 " ,
'/' => " %2F " ,
'?' => " %3F " ,
2019-09-14 22:44:40 +00:00
];
2019-09-13 15:02:56 +00:00
2019-09-10 00:38:27 +00:00
public function __construct () {
}
public function dispatch ( ServerRequestInterface $req ) : ResponseInterface {
2019-09-19 23:38:54 +00:00
// if the path contains a slash, this is not a URL we handle
$path = parse_url ( $req -> getRequestTarget ())[ 'path' ] ? ? " " ;
if ( strpos ( $path , " / " ) !== false ) {
2019-09-10 00:38:27 +00:00
return new EmptyResponse ( 404 );
}
2019-09-19 23:38:54 +00:00
// gather the query parameters and act on the "f" (function) parameter
$process = $req -> getQueryParams ()[ 'f' ] ? ? " " ;
$process = strlen ( $process ) ? $process : " discovery " ;
2019-09-14 22:44:40 +00:00
$method = $req -> getMethod ();
2019-09-19 23:38:54 +00:00
if ( isset ( self :: FUNCTIONS [ $process ]) || ( $process === " discovery " && ! strlen ( $path )) || ( $process !== " discovery " && strlen ( $path ))) {
// the function requested needs to exist
// the path should also be empty unless we're doing discovery
2019-09-10 00:38:27 +00:00
return new EmptyResponse ( 404 );
2019-09-14 22:44:40 +00:00
} elseif ( $method === " OPTIONS " ) {
$fields = [ 'Allow' => implode ( " , " , array_keys ( self :: FUNCTIONS [ $process ]))];
if ( isset ( self :: FUNCTIONS [ $process ][ 'POST' ])) {
$fields [ 'Accept' ] = " application/x-www-form-urlencoded " ;
}
return new EmptyResponse ( 204 , $fields );
} elseif ( isset ( self :: FUNCTIONS [ $process ][ $method ])) {
return new EmptyResponse ( 405 , [ 'Allow' => implode ( " , " , array_keys ( self :: FUNCTIONS [ $process ]))]);
2019-09-10 00:38:27 +00:00
} else {
2019-09-15 00:51:05 +00:00
try {
2019-09-19 23:38:54 +00:00
$func = self :: FUNCTIONS [ $process ][ $method ];
return $this -> $func ( $req );
2019-09-15 00:51:05 +00:00
} catch ( ExceptionAuth $e ) {
// human-readable error messages could be added, but these must be ASCII per OAuth, so there's probably not much point
// see https://tools.ietf.org/html/rfc6749#section-5.2
return new JsonResponse ([ 'error' => $e -> getMessage ()], 400 );
}
2019-09-10 00:38:27 +00:00
}
}
2019-09-19 23:38:54 +00:00
/** Produces the base URL of a server request
2019-09-13 15:02:56 +00:00
*
* This involves reconstructing the scheme and authority based on $_SERVER
* variables ; it may fail depending on server configuration
*/
2019-09-19 23:38:54 +00:00
protected function buildBaseURL ( ServerRequestInterface $req ) : string {
2019-09-10 00:38:27 +00:00
// construct the base user identifier URL; the user is never checked against the database
$s = $req -> getServerParams ();
2019-09-10 04:02:11 +00:00
$path = $req -> getRequestTarget ()[ 'path' ];
2019-09-10 00:38:27 +00:00
$https = ( strlen ( $s [ 'HTTPS' ] ? ? " " ) && $s [ 'HTTPS' ] !== " off " );
2019-09-13 15:02:56 +00:00
$port = ( int ) ( $s [ 'SERVER_PORT' ] ? ? 0 );
2019-09-10 00:38:27 +00:00
$port = ( ! $port || ( $https && $port == 443 ) || ( ! $https && $port == 80 )) ? " " : " : $port " ;
2019-09-19 23:38:54 +00:00
return URL :: normalize (( $https ? " https " : " http " ) . " :// " . $s [ 'HTTP_HOST' ] . $port . " / " );
}
2019-09-20 15:12:31 +00:00
/** Produces a canoncial identity URL based on a server request and a user name
*
* This involves reconstructing the scheme and authority based on $_SERVER
* variables ; it may fail depending on server configuration
*/
2019-09-19 23:38:54 +00:00
protected function buildIdentifier ( ServerRequestInterface $req , string $user ) : string {
return $this -> buildBaseURL ( $req ) . " u/ " . str_replace ( array_keys ( self :: USERNAME_ESCAPES ), array_values ( self :: USERNAME_ESCAPES ), $user );
}
2019-09-20 15:12:31 +00:00
/** Matches an identity URL against its canoncial form
*
* The identifier matches if all of the following are true :
*
* 1. The scheme is http or https
* 2. The normalized hostname matches
* 3. The port matches after dropping default port numbers
* 4. No credentials are included in the authority
* 5. The path is `/u/<username>`
* 6. There is no query content
* 7. The username , when URL - decoded , matches
*
* Though IndieAuth forbids port numbers and fragments in identifiers , we do not enforce this
*/
2019-09-19 23:38:54 +00:00
protected function matchIdentifier ( string $canonical , string $me ) : bool {
$me = parse_url ( URL :: normalize ( $me ));
$me [ 'scheme' ] = $me [ 'scheme' ] ? ? " " ;
$me [ 'path' ] = explode ( " / " , $me [ 'path' ] ? ? " " );
$me [ 'id' ] = rawurldecode ( array_pop ( $me [ 'path' ]) ? ? " " );
$me [ 'port' ] == (( $me [ 'scheme' ] === " http " && $me [ 'port' ] == 80 ) || ( $me [ 'scheme' ] === " https " && $me [ 'port' ] == 443 )) ? 0 : $me [ 'port' ];
$c = parse_url ( $canonical );
$c [ 'path' ] = explode ( " / " , $c [ 'path' ]);
$c [ 'id' ] = rawurldecode ( array_pop ( $c [ 'path' ]));
if (
! in_array ( $me [ 'scheme' ] ? ? " " , [ " http " , " https " ]) ||
( $me [ 'host' ] ? ? " " ) !== $c [ 'host' ] ||
$me [ 'path' ] != $c [ 'path' ] ||
$me [ 'id' ] !== $c [ 'id' ] ||
strlen ( $me [ 'user' ] ? ? " " ) ||
strlen ( $me [ 'pass' ] ? ? " " ) ||
strlen ( $me [ 'query' ] ? ? " " ) ||
( $me [ 'port' ] ? ? 0 ) != ( $c [ 'port' ] ? ? 0 )
) {
return false ;
}
return true ;
2019-09-10 04:02:11 +00:00
}
2019-09-13 15:02:56 +00:00
/** Presents a very basic user profile for discovery purposes
*
* The HTML document itself consists only of link elements and an
* encoding declaration ; Link header - fields are also included for
* HEAD requests
*
* Since discovery is publicly accessible , we produce a discovery
2019-09-19 23:38:54 +00:00
* page for all potential user names so as not to facilitate user
2019-09-13 15:02:56 +00:00
* enumeration
2019-09-14 22:44:40 +00:00
*
* @ see https :// indieweb . org / Microsub - spec #Discovery
2019-09-13 15:02:56 +00:00
*/
2019-09-19 23:38:54 +00:00
protected function opDiscovery ( ServerRequestInterface $req ) : ResponseInterface {
$base = $this -> buildBaseURL ( $req );
$urlAuth = $base . " u/?f=auth " ;
$urlToken = $base . " u/?f=token " ;
2019-09-10 00:38:27 +00:00
$urlService = $base . " microsub " ;
// output an extremely basic identity resource
$html = '<meta charset="UTF-8"><link rel="authorization_endpoint" href="' . htmlspecialchars ( $urlAuth ) . '"><link rel="token_endpoint" href="' . htmlspecialchars ( $urlToken ) . '"><link rel="microsub" href="' . htmlspecialchars ( $urlService ) . '">' ;
2019-09-14 22:44:40 +00:00
return new HtmlResponse ( $html , 200 , [
2019-09-10 00:38:27 +00:00
" Link: < $urlAuth >; rel= \" authorization_endpoint \" " ,
" Link: < $urlToken >; rel= \" token_endpoint \" " ,
" Link: < $urlService >; rel= \" microsub \" " ,
]);
}
2019-09-10 04:02:11 +00:00
2019-09-20 15:12:31 +00:00
/** Handles the authentication process
2019-09-13 15:02:56 +00:00
*
* Authentication is achieved via an HTTP Basic authentiation
* challenge ; once the user successfully logs in a code is issued
* and redirection occurs . Scopes are for all intents and purposes
* ignored and client information is not presented .
2019-09-14 22:44:40 +00:00
*
* @ see https :// indieauth . spec . indieweb . org / #authentication-request
2019-09-20 15:12:31 +00:00
* @ see https :// indieauth . spec . indieweb . org / #authorization-endpoint-0
2019-09-13 15:02:56 +00:00
*/
2019-09-19 23:38:54 +00:00
protected function opLogin ( ServerRequestInterface $req ) : ResponseInterface {
2019-09-10 04:02:11 +00:00
if ( ! $req -> getAttribute ( " authenticated " , false )) {
// user has not yet logged in, or has failed to log in
return new EmptyResponse ( 401 );
} else {
// user has logged in
$query = $req -> getQueryParams ();
2019-09-15 00:51:05 +00:00
$redir = URL :: normalize ( rawurldecode ( $query [ 'redirect_uri' ]));
// check that the redirect URL is an absolute one
if ( ! URL :: absolute ( $redir )) {
return new EmptyResponse ( 400 );
}
try {
// ensure the logged-in user matches the IndieAuth identifier URL
2019-09-19 23:38:54 +00:00
$user = $req -> getAttribute ( " authenticatedUser " );
if ( ! $this -> matchIdentifier ( $this -> buildIdentifier ( $req , $user ), $query [ 'me' ])) {
2019-09-15 00:51:05 +00:00
throw new ExceptionAuth ( " access_denied " );
2019-09-10 21:48:38 +00:00
}
2019-09-15 00:51:05 +00:00
$type = ! strlen ( $query [ 'response_type' ] ? ? " " ) ? " id " : $query [ 'response_type' ];
if ( ! in_array ( $type , [ " code " , " id " ])) {
throw new ExceptionAuth ( " unsupported_response_type " );
}
$state = $query [ 'state' ] ? ? " " ;
2019-09-19 23:38:54 +00:00
// store the identity URL, client ID, redirect URL, and response type
2019-09-14 22:44:40 +00:00
$data = json_encode ([
2019-09-19 23:38:54 +00:00
'me' => $query [ 'me' ],
'client' => $query [ 'client_id' ],
'redir' => $query [ 'redirect_uri' ],
2019-09-15 00:51:05 +00:00
'type' => $type ,
2019-09-14 22:44:40 +00:00
], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE );
2019-09-10 21:48:38 +00:00
// issue an authorization code and build the redirect URL
2019-09-19 23:38:54 +00:00
$code = Arsse :: $db -> tokenCreate ( $user , " microsub.auth " , null , Date :: add ( " PT2M " ), $data );
2019-09-10 21:48:38 +00:00
$next = URL :: queryAppend ( $redir , " code= $code &state= $state " );
return new EmptyResponse ( 302 , [ " Location: $next " ]);
2019-09-15 00:51:05 +00:00
} catch ( ExceptionAuth $e ) {
$next = URL :: queryAppend ( $redir , " state= $state &error= " . $e -> getMessage ());
return new EmptyResponse ( 302 , [ " Location: $next " ]);
2019-09-10 04:02:11 +00:00
}
}
}
2019-09-13 01:19:26 +00:00
2019-09-20 15:12:31 +00:00
/** Handles the auth code verification of the basic " Authentication " flow of IndieAuth
2019-09-14 22:44:40 +00:00
*
2019-09-20 22:49:09 +00:00
* This is not used by Microsub , but is part of the IndieAuth specification
2019-09-14 22:44:40 +00:00
*
* @ see https :// indieauth . spec . indieweb . org / #authorization-code-verification
*/
2019-09-19 23:38:54 +00:00
protected function opCodeVerification ( ServerRequestInterface $req ) : ResponseInterface {
2019-09-14 22:44:40 +00:00
$post = $req -> getParsedBody ();
2019-09-20 15:12:31 +00:00
$tr = Arsse :: $db -> begin ();
// validate the request parameters; an exception will be thrown if not
list ( $user , $type ) = $this -> validateAuthCode ( $post [ 'code' ] ? ? " " , $post [ 'client_id' ] ? ? " " , $post [ 'redirect_uri' ] ? ? " " );
if ( $type !== " id " ) {
throw new ExceptionAuth ( " invalid_grant " );
}
// delete the auth code since it is valid and may only be used once
Arsse :: $db -> tokenRevoke ( $user , " microsub.auth " , $post [ 'code' ]);
$tr -> commit ();
// return the canonical identity URL
return new JsonResponse ([ 'me' => $this -> buildIdentifier ( $req , $user )]);
}
/** Handles the auth code verification and token issuance of the " Authorization " flow of IndieAuth
*
* @ see https :// indieauth . spec . indieweb . org / #token-endpoint-0
*/
protected function opIssueAccessToken ( ServerRequestInterface $req ) : ResponseInterface {
$post = $req -> getParsedBody ();
if (( $post [ 'grant_type' ] ? ? " " ) !== " authorization_code " ) {
throw new ExceptionAuth ( " unsupported_grant_type " );
}
$tr = Arsse :: $db -> begin ();
list ( $user , $type ) = $this -> validateAuthCode ( $post [ 'code' ] ? ? " " , $post [ 'client_id' ] ? ? " " , $post [ 'redirect_url' ] ? ? " " , $post [ 'me' ] ? ? " " );
if ( $type !== " code " ) {
throw new ExceptionAuth ( " invalid_grant " );
}
// issue an access token
$token = Arsse :: $db -> tokenCreate ( $user , " microsub.access " );
Arsse :: $db -> tokenRevoke ( $user , " microsub.auth " , $post [ 'code' ]);
$tr -> commit ();
// return the Bearer token and associated data
return new JsonResponse ([
'me' => $this -> buildIdentifier ( $req , $user ),
'token_type' => " Bearer " ,
'access_token' => $token ,
'scope' => self :: SCOPES ,
]);
}
/** Validates an auth code and throws appropriate exceptions otherwise
*
2019-09-20 22:49:09 +00:00
* Returns an indexed array containing the username and the grant type ( either " id " or " code " )
2019-09-20 15:12:31 +00:00
*
2019-09-20 22:49:09 +00:00
* It is the responsibility of the calling function to revoke the auth code if the code is ultimately accepted
2019-09-20 15:12:31 +00:00
*/
protected function validateAuthCode ( string $code , string $clientId , string $redirUrl , string $me = null ) : array {
if ( ! strlen ( $code ) || ! strlen ( $clientId ) || ! strlen ( $redirUrl )) {
2019-09-15 00:51:05 +00:00
throw new ExceptionAuth ( " invalid_request " );
}
2019-09-20 15:12:31 +00:00
// check that the auth code exists
2019-09-20 22:49:09 +00:00
try {
$token = Arsse :: $db -> tokenLookup ( " microsub.auth " , $code );
} catch ( \JKingWeb\Arsse\Db\ExceptionInput $e ) {
2019-09-15 03:05:30 +00:00
throw new ExceptionAuth ( " invalid_grant " );
2019-09-15 00:51:05 +00:00
}
$data = @ json_decode ( $token [ 'data' ], true );
2019-09-20 15:12:31 +00:00
// validate the auth code
2019-09-19 23:38:54 +00:00
if ( ! is_array ( $data )) {
2019-09-15 03:05:30 +00:00
throw new ExceptionAuth ( " invalid_grant " );
2019-09-20 15:12:31 +00:00
} elseif ( $data [ 'client' ] !== $clientId || $data [ 'redir' ] !== $redirUrl ) {
2019-09-15 03:05:30 +00:00
throw new ExceptionAuth ( " invalid_client " );
2019-09-20 15:12:31 +00:00
} elseif ( isset ( $me ) && $me !== $data [ 'me' ]) {
2019-09-15 03:05:30 +00:00
throw new ExceptionAuth ( " invalid_grant " );
}
2019-09-20 15:12:31 +00:00
// return the associated user name and the auth-code type
return [ $token [ 'user' ], $data [ 'type' ] ? ? " id " ];
2019-09-15 03:05:30 +00:00
}
protected function opTokenVerification ( string $user , ServerRequestInterface $req ) : ResponseInterface {
2019-09-13 01:19:26 +00:00
}
2019-09-20 22:49:09 +00:00
/** Checks that the simplied bearer token is valid
*
* Returns an indexed array with the user associated with the token , as well as the granted scope
*
* @ throws \JKingWeb\Arsse\REST\Microsub\ExceptionAuth
*/
public static function validateBearer ( string $token ) : array {
try {
$token = Arsse :: $db -> tokenLookup ( " microsub.auth " , $token );
} catch ( \JKingWeb\Arsse\Db\ExceptionInput $e ) {
throw new ExceptionAuth ( " invalid_grant " );
}
// scope is hard-coded for now
return [ $token [ 'user' ], self :: SCOPES ];
}
2019-09-10 00:38:27 +00:00
}