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-26 23:44:25 +00:00
use JKingWeb\Arsse\Misc\ValueInfo ;
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 */
2019-09-23 02:17:42 +00:00
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 = [
2019-09-25 12:23:42 +00:00
'' => [ 'GET' => " opDiscovery " ],
'auth' => [ 'GET' => " opLogin " , 'POST' => " opCodeVerification " ],
'token' => [ 'GET' => " opTokenVerification " , 'POST' => " opIssueAccessToken " ],
2019-09-19 23:38:54 +00:00
];
2019-09-26 23:44:25 +00:00
/** The minimal set of reserved URL characters which mus t be escaped when comparing user ID URLs */
2019-09-19 23:38:54 +00:00
const USERNAME_ESCAPES = [
'#' => " %23 " ,
'%' => " %25 " ,
'/' => " %2F " ,
'?' => " %3F " ,
2019-09-14 22:44:40 +00:00
];
2019-09-26 23:44:25 +00:00
/** The minimal set of reserved URL characters which must be escaped in query values */
const QUERY_ESCAPES = [
'#' => " %23 " ,
'%' => " %25 " ,
'&' => " %26 " ,
];
/** The acceptable media type of input for POST requests */
const ACCEPTED_TYPES = " application/x-www-form-urlencoded " ;
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' ] ? ? " " ;
2019-09-14 22:44:40 +00:00
$method = $req -> getMethod ();
2019-09-26 23:44:25 +00:00
if ( ! isset ( self :: FUNCTIONS [ $process ]) || ( $process === " " && ! strlen ( $path )) || ( $process !== " " && strlen ( $path ))) {
2019-09-19 23:38:54 +00:00
// 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' ])) {
2019-09-26 23:44:25 +00:00
$fields [ 'Accept' ] = self :: ACCEPTED_TYPES ;
2019-09-14 22:44:40 +00:00
}
return new EmptyResponse ( 204 , $fields );
2019-09-26 23:44:25 +00:00
} elseif ( ! isset ( self :: FUNCTIONS [ $process ][ $method ])) {
2019-09-14 22:44:40 +00:00
return new EmptyResponse ( 405 , [ 'Allow' => implode ( " , " , array_keys ( self :: FUNCTIONS [ $process ]))]);
2019-09-10 00:38:27 +00:00
} else {
2019-09-26 23:44:25 +00:00
if ( $req -> getMethod () !== " GET " ) {
$type = $req -> getHeaderLine ( " Content-Type " ) ? ? " " ;
if ( strlen ( $type ) && strtolower ( $type ) !== self :: ACCEPTED_TYPES ) {
return new EmptyResponse ( 415 , [ 'Accept' => self :: ACCEPTED_TYPES ]);
}
}
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-26 23:44:25 +00:00
$https = ValueInfo :: normalize ( $s [ 'HTTPS' ] ? ? " " , ValueInfo :: T_BOOL );
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' ]) ? ? " " );
2019-09-27 21:09:30 +00:00
$me [ 'port' ] = (( $me [ 'scheme' ] === " http " && ( $me [ 'port' ] ? ? 80 ) == 80 ) || ( $me [ 'scheme' ] === " https " && ( $me [ 'port' ] ? ? 443 ) == 443 )) ? 0 : $me [ 'port' ] ? ? 0 ;
2019-09-19 23:38:54 +00:00
$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-26 23:44:25 +00:00
return new HtmlResponse ( $html , 200 , [ 'Link' => [
" < $urlAuth >; rel= \" authorization_endpoint \" " ,
" < $urlToken >; rel= \" token_endpoint \" " ,
" < $urlService >; rel= \" microsub \" " ,
]]);
2019-09-10 00:38:27 +00:00
}
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-27 21:09:30 +00:00
$redir = URL :: normalize ( $query [ 'redirect_uri' ]);
2019-09-15 00:51:05 +00:00
// check that the redirect URL is an absolute one
if ( ! URL :: absolute ( $redir )) {
return new EmptyResponse ( 400 );
}
try {
2019-09-27 21:09:30 +00:00
$state = $query [ 'state' ] ? ? " " ;
2019-09-15 00:51:05 +00:00
// 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 " );
}
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' ],
2019-09-27 21:09:30 +00:00
'client_id' => $query [ 'client_id' ],
'redirect_uri' => $query [ 'redirect_uri' ],
'response_type' => $type ,
2019-09-24 17:59:52 +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 " );
2019-09-27 21:09:30 +00:00
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 ());
2019-09-27 21:09:30 +00:00
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 ();
2019-09-24 17:59:52 +00:00
// revocation is a special case of POSTing to the token URL
if ( $post [ 'action' ] ? ? " " === " revoke " ) {
return $this -> opRevokeToken ( $req );
}
2019-09-20 15:12:31 +00:00
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
2019-09-24 17:59:52 +00:00
$data = json_encode ([
'me' => $post [ 'me' ],
'client_id' => $post [ 'client_id' ],
], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE );
$token = Arsse :: $db -> tokenCreate ( $user , " microsub.access " , null , null , $data );
2019-09-20 15:12:31 +00:00
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 ,
2019-09-23 02:17:42 +00:00
'scope' => implode ( " " , self :: SCOPES ),
2019-09-20 15:12:31 +00:00
]);
}
/** 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-27 21:09:30 +00:00
} elseif ( $data [ 'client_id' ] !== $clientId || $data [ 'redirect_uri' ] !== $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
2019-09-27 21:09:30 +00:00
return [ $token [ 'user' ], $data [ 'response_type' ] ? ? " id " ];
2019-09-15 03:05:30 +00:00
}
2019-09-24 17:59:52 +00:00
/** Handles token verification as an API call
*
* The static `validateBearer` method should be used to check the validity of a bearer token in normal use
*
* @ see https :// indieauth . spec . indieweb . org / #access-token-verification
*/
protected function opTokenVerification ( ServerRequestInterface $req ) : ResponseInterface {
try {
if ( ! $req -> hasHeader ( " Authorization " )) {
throw new ExceptionAuth ( " invalid_token " );
}
$authorization = $req -> getHeader ( " Authorization " )[ 0 ];
list ( $user , $data ) = self :: validateBearer ( $authorization );
} catch ( ExceptionAuth $e ) {
$errCode = $e -> getMessage ();
$httpCode = [
'invalid_request' => 400 ,
'invalid_token' => 401 ,
][ $errCode ] ? ? 500 ;
2019-09-27 23:14:15 +00:00
return new EmptyResponse ( $httpCode , [
'WWW-Authenticate' => " Bearer error= \" $errCode\ " " ,
'X-Arsse-Suppress-General-Auth' => " 1 "
]);
2019-09-24 17:59:52 +00:00
}
return new JsonResponse ([
'me' => $data [ 'me' ] ? ? " " ,
'client_id' => $data [ 'client_id' ] ? ? " " ,
'scope' => $data [ 'scope' ] ? ? self :: SCOPES ,
]);
2019-09-13 01:19:26 +00:00
}
2019-09-20 22:49:09 +00:00
2019-09-24 17:59:52 +00:00
/** Handles token revocation
2019-09-20 22:49:09 +00:00
*
2019-09-24 17:59:52 +00:00
* @ see https :// indieauth . spec . indieweb . org / #token-revocation
*/
protected function opRevokeToken ( ServerRequestInterface $req ) : ResponseInterface {
$token = ( $req -> getParsedBody () ? ? [])[ 'token' ] ? ? " " ;
if ( ! strlen ( $token )) {
return new EmptyResponse ( 422 );
}
try {
$info = Arsse :: $db -> tokenLookup ( " microsub.access " , $token );
Arsse :: $db -> tokenRevoke ( $info [ 'user' ], " mucrosub.access " , $token );
} catch ( \JKingWeb\Arsse\Db\ExceptionInput $e ) {
}
return new EmptyResponse ( 200 );
}
/** Checks that the supplied bearer token is valid i . e . logs a bearer in
2019-09-20 22:49:09 +00:00
*
2019-09-24 17:59:52 +00:00
* Returns an indexed array with the user associated with the token , as well as other data
*
2019-09-20 22:49:09 +00:00
* @ throws \JKingWeb\Arsse\REST\Microsub\ExceptionAuth
*/
2019-09-23 02:17:42 +00:00
public static function validateBearer ( string $authorization , array $scopes = []) : array {
if ( ! preg_match ( " /^Bearer (.+)/ " , $authorization , $match )) {
throw new ExceptionAuth ( " invalid_request " );
}
$token = $match [ 1 ];
2019-09-20 22:49:09 +00:00
try {
2019-09-23 02:17:42 +00:00
$token = Arsse :: $db -> tokenLookup ( " microsub.access " , $token );
2019-09-20 22:49:09 +00:00
} catch ( \JKingWeb\Arsse\Db\ExceptionInput $e ) {
2019-09-23 02:17:42 +00:00
throw new ExceptionAuth ( " invalid_token " );
2019-09-20 22:49:09 +00:00
}
2019-09-24 17:59:52 +00:00
$data = @ json_decode ( $token [ 'data' ], true ) ? ? [];
$data [ 'scope' ] = $data [ 'scope' ] ? ? self :: SCOPES ;
2019-09-20 22:49:09 +00:00
// scope is hard-coded for now
2019-09-24 17:59:52 +00:00
if ( array_diff ( $scopes , $data [ 'scope' ])) {
2019-09-23 02:17:42 +00:00
throw new ExceptionAuth ( " insufficient_scope " );
}
2019-09-24 17:59:52 +00:00
return [ $token [ 'user' ], $data ];
2019-09-20 22:49:09 +00:00
}
2019-09-10 00:38:27 +00:00
}