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 . " / " );
}
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 );
}
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-13 15:02:56 +00:00
/** Handles the authentication / authorization process
*
* 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-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-14 22:44:40 +00:00
/** Validates an authorization code against client - provided values
*
* The redirect URL and client ID are checked , as is the user ID
*
* If everything checks out the canonical user URL is supposed to be returned ;
* we don ' t actually know what the canonical URL is modulo URL encoding , but it
* doesn ' t actually matter for our purposes
*
* @ see https :// indieauth . spec . indieweb . org / #authorization-code-verification
2019-09-15 00:51:05 +00:00
* @ see https :// indieauth . spec . indieweb . org / #authorization-code-verification-0
2019-09-14 22:44:40 +00:00
*/
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-15 00:51:05 +00:00
// validate the request parameters
$code = $post [ 'code' ] ? ? " " ;
2019-09-19 23:38:54 +00:00
$client = $post [ 'client_id' ] ? ? " " ;
$redir = $post [ 'redirect_uri' ] ? ? " " ;
if ( ! strlen ( $code ) || ! strlen ( $client ) || ! strlen ( $redir )) {
2019-09-15 00:51:05 +00:00
throw new ExceptionAuth ( " invalid_request " );
}
// check that the token exists
$token = Arsse :: $db -> tokenLookup ( " microsub.auth " , $code );
if ( ! $token ) {
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 );
// validate the token
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-19 23:38:54 +00:00
} elseif ( $data [ 'client' ] !== $client || $data [ 'redir' ] !== $redir ) {
2019-09-15 03:05:30 +00:00
throw new ExceptionAuth ( " invalid_client " );
2019-09-15 00:51:05 +00:00
} else {
2019-09-19 23:38:54 +00:00
$out = [ 'me' => $this -> buildIdentifier ( $req , $token [ 'user' ])];
2019-09-15 00:51:05 +00:00
if ( $data [ 'type' ] === " code " ) {
$out [ 'scope' ] = self :: SCOPES ;
2019-09-14 22:44:40 +00:00
}
2019-09-15 00:51:05 +00:00
return new JsonResponse ( $out );
2019-09-14 22:44:40 +00:00
}
}
2019-09-13 15:02:56 +00:00
2019-09-19 23:38:54 +00:00
protected function opIssueAccessToken ( ServerRequestInterface $req ) : ResponseInterface {
2019-09-14 22:44:40 +00:00
$post = $req -> getParsedBody ();
2019-09-15 03:05:30 +00:00
$type = $post [ 'grant_type' ] ? ? " " ;
$me = $post [ 'me' ] ? ? " " ;
if ( $type !== " authorization_code " ) {
throw new ExceptionAuth ( " unsupported_grant_type " );
} elseif ( $this -> buildIdentifier ( $req ) !== $me ) {
throw new ExceptionAuth ( " invalid_grant " );
} else {
$out = $this -> opCodeVerification ( $user , $req ) -> getPayload ();
if ( ! isset ( $out [ 'scope' ])) {
throw new ExceptionAuth ( " invalid_scope " );
}
// issue an access token
$tr = Arsse :: $db -> begin ();
$token = Arsse :: $db -> tokenCreate ( $user , " microsub.access " );
Arsse :: $db -> tokenRevoke ( $user , " microsub.auth " , $post [ 'code' ]);
$tr -> commit ();
$out [ 'access_token' ] = $token ;
$out [ 'token_type' ] = " Bearer " ;
return new JsonResponse ( $out );
}
}
protected function opTokenVerification ( string $user , ServerRequestInterface $req ) : ResponseInterface {
2019-09-13 01:19:26 +00:00
}
2019-09-10 00:38:27 +00:00
}