Merged OAuth2 handling into Misuzu.

This commit is contained in:
flash 2025-02-02 02:09:56 +00:00
parent 1994a9892d
commit 534e947522
115 changed files with 4556 additions and 77 deletions
VERSION
assets
build.js
config
database
public-legacy
public
src

View file

@ -1 +1 @@
20250130.2
20250201

View file

@ -106,24 +106,31 @@ const $e = function(info, attrs, child, created) {
for(const child of children) {
switch(typeof child) {
case 'string':
elem.appendChild($t(child));
elem.appendChild(document.createTextNode(child));
break;
case 'object':
if(child instanceof Element)
if(child instanceof Element) {
elem.appendChild(child);
else if(child.getElement) {
} else if('element' in child) {
const childElem = child.element;
if(childElem instanceof Element)
elem.appendChild(childElem);
else
elem.appendChild($e(child));
} else if('getElement' in child) {
const childElem = child.getElement();
if(childElem instanceof Element)
elem.appendChild(childElem);
else
elem.appendChild($e(child));
} else
} else {
elem.appendChild($e(child));
}
break;
default:
elem.appendChild($t(child.toString()));
elem.appendChild(document.createTextNode(child.toString()));
break;
}
}

View file

@ -0,0 +1,43 @@
.oauth2-appinfo {}
.oauth2-appinfo-name {
font-size: 2em;
line-height: 1.5em;
}
.oauth2-appinfo-links {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.oauth2-appinfo-link {
display: flex;
color: inherit;
gap: 5px;
align-items: center;
background: #333;
padding: 2px 6px;
border-radius: 4px;
}
.oauth2-appinfo-link-icon {
flex: 0 0 auto;
background-color: #fff;
width: 12px;
height: 12px;
}
.oauth2-appinfo-link-icon-globe {
mask: url('/images/globe-solid.svg') no-repeat center;
}
.oauth2-appinfo-link-text {
font-size: .8em;
line-height: 1.4em;
}
.oauth2-appinfo-summary {
font-size: .9em;
line-height: 1.4em;
}
.oauth2-appinfo-summary p {
margin: .5em 0;
}

View file

@ -0,0 +1,35 @@
.oauth2-approval {
display: flex;
flex-direction: column;
align-items: center;
}
.oauth2-approval-icon {
flex: 0 0 auto;
background-color: #fff;
width: 40px;
height: 40px;
margin: 10px;
}
.oauth2-approval-icon-approved {
mask: url('/images/circle-check-solid.svg') no-repeat center;
}
.oauth2-approval-icon-denied {
mask: url('/images/circle-xmark-solid.svg') no-repeat center;
}
.oauth2-approval-header {
text-align: center;
font-size: 1.2em;
line-height: 1.5em;
}
.oauth2-approval-text {
width: 100%;
}
.oauth2-approval-text p {
margin: .5em 0;
font-size: .8em;
line-height: 1.5em;
}

View file

@ -0,0 +1,72 @@
.oauth2-authorise-requesting {
font-size: .8em;
line-height: 1.4em;
border-bottom: 1px solid #333;
}
.oauth2-authorise-requesting p {
margin: .5em 0;
}
.oauth2-authorise-device {
font-size: .8em;
line-height: 1.4em;
}
.oauth2-authorise-device p {
margin: .5em 0;
}
.oauth2-authorise-buttons {
margin-top: 10px;
display: flex;
justify-content: center;
gap: 10px;
}
.oauth2-authorise-button {
background-color: #191919;
font-family: var(--font-regular);
font-size: 1.2em;
line-height: 1.4em;
padding: 5px 10px;
min-width: 140px;
text-align: center;
cursor: pointer;
transition: color .2s, background-color .2s, opacity .2s;
border: 1px solid;
border-radius: 2px;
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
color: #8559a5;
border-color: #8559a5;
}
.oauth2-authorise-button:hover,
.oauth2-authorise-button:focus {
color: #191919;
background-color: #8559a5;
}
.oauth2-authorise-button[disabled] {
opacity: .5;
}
.oauth2-authorise-button-accept {
color: #080;
border-color: #0a0;
}
.oauth2-authorise-button-accept:hover,
.oauth2-authorise-button-accept:focus {
color: #191919;
background-color: #0a0;
}
.oauth2-authorise-button-deny {
color: #c00;
border-color: #a00;
}
.oauth2-authorise-button-deny:hover,
.oauth2-authorise-button-deny:focus {
color: #191919;
background-color: #a00;
}

View file

@ -0,0 +1,21 @@
.oauth2-banner {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.oauth2-banner-text {
font-size: .9em;
line-height: 1.4em;
flex: 1 1 auto;
}
.oauth2-banner-logo {
flex: 0 0 auto;
background-color: #fff;
mask: url('/images/flashii.svg') no-repeat center;
width: 30px;
height: 30px;
font-size: 0;
}

View file

@ -0,0 +1,24 @@
.oauth2-device-form {
display: flex;
justify-content: center;
margin: 10px;
}
.oauth2-device-code {
font-size: 1.4em;
border: 1px solid #222;
padding: 5px 10px;
background: #222;
color: #fff;
border-radius: 2px;
box-shadow: inset 0 0 4px #111;
transition: border-color .2s;
text-align: center;
font-family: var(--font-monospace);
min-width: 0;
max-width: 200px;
width: 100%;
}
.oauth2-device-code:focus {
border-color: #8559a5;
}

View file

@ -0,0 +1,3 @@
.oauth2-errorbody p {
margin: .5em 1em;
}

View file

@ -0,0 +1,36 @@
.oauth2-loading {
display: flex;
justify-content: center;
flex-direction: column;
min-height: 200px;
}
.oauth2-loading-frame {
display: flex;
justify-content: center;
flex: 0 0 auto;
}
.oauth2-loading-icon {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 2px;
margin: 20px;
}
.oauth2-loading-icon-block {
background: #fff;
width: 20px;
height: 20px;
}
.oauth2-loading-icon-block-hidden {
opacity: 0;
}
.oauth2-loading-text {
text-align: center;
font-size: 1.2em;
line-height: 1.5em;
}

View file

@ -0,0 +1,97 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
position: relative;
}
html, body {
width: 100%;
height: 100%;
}
[hidden],
.hidden {
display: none !important;
}
:root {
--font-regular: Verdana, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif;
--font-monospace: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
}
body {
background-color: #111;
color: #fff;
font-size: 16px;
line-height: 25px;
font-family: var(--font-regular);
overflow-y: scroll;
position: static;
display: flex;
flex-direction: column;
}
pre, code {
font-family: var(--font-monospace);
}
a {
color: #1e90ff;
text-decoration: none;
}
a:visited {
color: #6B4F80;
}
a:hover,
a:focus {
text-decoration: underline;
}
.oauth2-wrapper {
display: flex;
flex-direction: column;
flex: 1 0 auto;
margin-bottom: 10px;
}
.oauth2-dialog {
display: flex;
flex: 1 0 auto;
padding: 10px;
width: 100%;
align-items: center;
justify-content: center;
}
.oauth2-dialog-body {
max-width: 500px;
width: 100%;
background: #191919;
box-shadow: 0 1px 2px #0009;
display: flex;
flex-direction: column;
}
.oauth2-header {
background-image: url('/images/clouds.png');
background-blend-mode: multiply;
background-color: #8559a5;
width: 100%;
min-height: 4px;
}
.oauth2-body {
margin: 10px;
}
@include loading.css;
@include banner.css;
@include error.css;
@include device.css;
@include simplehead.css;
@include userhead.css;
@include appinfo.css;
@include scope.css;
@include authorise.css;
@include approval.css;

View file

@ -0,0 +1,38 @@
.oauth2-scope {
background: #292929;
border-radius: 4px;
padding: 4px 8px;
}
.oauth2-scope-header {
border-bottom: 1px solid #494949;
}
.oauth2-scope-perms {
display: flex;
flex-direction: column;
gap: 4px;
margin: 4px 0;
}
.oauth2-scope-perm {
display: flex;
align-items: center;
gap: 4px;
}
.oauth2-scope-perm-icon {
width: 16px;
height: 16px;
mask: url('/images/circle-check-regular.svg') no-repeat center;
flex: 0 0 auto;
background-color: #0a0;
margin: 2px;
}
.oauth2-scope-perm-icon-warn {
mask: url('/images/circle-regular.svg') no-repeat center, url('/images/exclamation-solid.svg') no-repeat center center / 10px 10px;
background-color: #c80;
}
.oauth2-scope-perm-text {
font-size: .8em;
line-height: 1.4em;
}

View file

@ -0,0 +1,30 @@
.oauth2-simplehead {
display: flex;
align-items: center;
}
.oauth2-simplehead-icon {
flex: 0 0 auto;
background-color: #fff;
mask: url('/images/circle-question-solid.svg') no-repeat center;
width: 40px;
height: 40px;
margin: 10px;
}
.oauth2-simplehead-icon--code {
mask-image: url('/images/mobile-screen-solid.svg');
}
.oauth2-simplehead-icon--error {
mask-image: url('/images/circle-exclamation-solid.svg');
}
.oauth2-simplehead-icon--login {
mask-image: url('/images/user-lock-solid.svg');
}
.oauth2-simplehead-icon--wait {
mask-image: url('/images/ellipsis-solid.svg');
}
.oauth2-simplehead-text {
font-size: 1.8em;
line-height: 1.4em;
}

View file

@ -0,0 +1,64 @@
.oauth2-userhead {
display: flex;
flex-direction: column;
background-color: #0005;
}
.oauth2-userhead-main {
display: flex;
align-items: center;
}
.oauth2-userhead-main-avatar {
flex: 0 0 auto;
margin: 10px;
}
.oauth2-userhead-main-avatar-image {
width: 60px;
height: 60px;
overflow: hidden;
border-radius: 4px;
}
.oauth2-userhead-main-avatar-image img {
width: 100%;
height: 100%;
object-fit: cover;
border: 0;
}
.oauth2-userhead-main-name {
font-size: 1.8em;
line-height: 1.4em;
}
.oauth2-userhead-main-name a {
color: inherit;
}
.oauth2-userhead-guise {
display: flex;
align-items: center;
background-image: repeating-linear-gradient(-45deg, #8559a57f, #8559a57f 10px, #1111117f 10px, #1111117f 20px);
}
.oauth2-userhead-guise-avatar {
flex: 0 0 auto;
margin: 10px;
}
.oauth2-userhead-guise-avatar-image {
width: 30px;
height: 30px;
overflow: hidden;
border-radius: 4px;
}
.oauth2-userhead-guise-avatar-image img {
width: 100%;
height: 100%;
object-fit: cover;
border: 0;
}
.oauth2-userhead-guise-text {
font-size: .8em;
line-height: 1.5em;
overflow: hidden;
padding: 2px 0;
}
.oauth2-userhead-guise-text p {
margin: 1px 0;
}

View file

@ -0,0 +1,30 @@
const MszOAuth2AppInfoLink = function(info) {
const element = <a href={info.uri} target="_blank" rel="noopener noreferrer" class="oauth2-appinfo-link" title={info.title}>
<div class="oauth2-appinfo-link-icon oauth2-appinfo-link-icon-globe"></div>
<div class="oauth2-appinfo-link-text">{info.display}</div>
</a>;
return {
get element() { return element; },
};
};
const MszOAuth2AppInfo = function(info) {
const linksElem = <div class="oauth2-appinfo-links"/>;
if(Array.isArray(info.links))
for(const link of info.links)
linksElem.appendChild((new MszOAuth2AppInfoLink(link)).element);
// TODO: author should be listed
const element = <div class="oauth2-appinfo">
<div class="oauth2-appinfo-name">{info.name}</div>
{linksElem}
<div class="oauth2-appinfo-summary">
<p>{info.summary}</p>
</div>
</div>;
return {
get element() { return element; },
};
};

View file

@ -0,0 +1,33 @@
const MszOAuth2AppScopeEntry = function(text, warn) {
const icon = <div class="oauth2-scope-perm-icon"/>;
if(warn)
icon.classList.add('oauth2-scope-perm-icon-warn');
const element = <div class="oauth2-scope-perm">
{icon}
<div class="oauth2-scope-perm-text">{text}</div>
</div>;
return {
get element() { return element; },
};
};
const MszOAuth2AppScopeList = function(scopes) {
const permsElem = <div class="oauth2-scope-perms"/>;
if(Array.isArray(scopes) && scopes.length > 0) {
for(const scope of scopes)
if(typeof scope === 'string')
permsElem.appendChild(new MszOAuth2AppScopeEntry(scope).element);
} else
permsElem.appendChild(new MszOAuth2AppScopeEntry('A limited amount of things. No scope was specified by the developer.', true).element);
const element = <div class="oauth2-scope">
<div class="oauth2-scope-header">This application will be able to:</div>
{permsElem}
</div>;
return {
get element() { return element; },
};
};

View file

@ -0,0 +1,232 @@
#include loading.jsx
#include xhr.js
#include app/info.jsx
#include app/scope.jsx
#include header/header.js
#include header/user.jsx
const MszOAuth2AuthoriseErrors = Object.freeze({
'invalid_request': {
description: 'The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed.',
},
'unauthorized_client': {
description: 'The client is not authorised to request an authorisation code using this method.',
},
'access_denied': {
description: 'The resource owner or authorization server denied the request.',
},
'unsupported_response_type': {
description: 'The authorisation server does not support obtaining an authorisation code using this method.',
},
'invalid_scope': {
description: 'The requested scope is invalid, unknown, or malformed.',
},
'server_error': {
description: 'The authorisation server encountered an unexpected condition that prevented it from fulfilling the request.',
},
'temporarily_unavailable': {
description: 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.',
},
});
const MszOAuth2Authorise = async () => {
const queryParams = new URLSearchParams(window.location.search);
const loading = new MszOAuth2Loading('.js-loading');
const header = new MszOAuth2Header;
const fAuths = document.querySelector('.js-authorise-form');
const eAuthsInfo = document.querySelector('.js-authorise-form-info');
const eAuthsScope = document.querySelector('.js-authorise-form-scope');
const dError = document.querySelector('.js-authorise-error');
const dErrorText = dError?.querySelector('.js-authorise-error-text');
let scope;
let state;
let redirectUri;
let redirectUriRaw;
const displayError = (error, description, documentation) => {
if(redirectUri === undefined) {
if(error in MszOAuth2AuthoriseErrors) {
const errInfo = MszOAuth2AuthoriseErrors[error];
description ??= errInfo.description;
} else
description = `An unknown error occurred: ${error}`;
dErrorText.textContent = description;
header.setSimpleData('error', 'An error occurred!');
header.removeElement();
loading.visible = false;
fAuths.classList.add('hidden');
dError.classList.remove('hidden');
return;
}
const errorUri = new URL(redirectUri);
errorUri.searchParams.set('error', error?.toString() ?? 'invalid_request');
if(description)
errorUri.searchParams.set('error_description', description.toString());
if(documentation)
errorUri.searchParams.set('error_uri', documentation.toString());
if(state !== undefined)
errorUri.searchParams.set('state', state.toString());
window.location.assign(errorUri);
};
const translateError = (serverError, detail) => {
if(serverError === 'auth')
return displayError('access_denied');
if(serverError === 'csrf')
return displayError('invalid_request', 'Request verification failed.');
if(serverError === 'client')
return displayError('invalid_request', 'There is no application associated with the specified Client ID.');
if(serverError === 'format')
return displayError('invalid_request', 'Redirect URI specified is not registered with this application.');
if(serverError === 'method')
return displayError('invalid_request', 'Requested code challenge method is not supported.');
if(serverError === 'length')
return displayError('invalid_request', 'Code challenge length is not acceptable.');
if(serverError === 'required')
return displayError('invalid_request', 'A registered redirect URI must be specified.');
if(serverError === 'scope')
return displayError('invalid_scope', detail === undefined ? undefined : `Requested scope "${detail.scope}" is ${detail.reason}.`);
if(serverError === 'authorise')
return displayError('server_error', 'Server was unable to complete authorisation.');
return displayError('invalid_request', `An unknown error occurred: ${serverError}.`);
};
if(queryParams.has('redirect_uri'))
try {
const qRedirectUriRaw = queryParams.get('redirect_uri');
const qRedirectUri = new URL(qRedirectUriRaw);
if(qRedirectUri.protocol !== 'https:')
throw 'protocol must be https';
redirectUri = qRedirectUri;
redirectUriRaw = qRedirectUriRaw;
} catch(ex) {
return displayError('invalid_request', 'Invalid redirect URI specified.');
}
if(queryParams.has('state')) {
const qState = queryParams.get('state');
if(qState.length > 1000)
return displayError('invalid_request', 'State parameter may not be longer than 255 characters.');
state = qState;
}
if(queryParams.get('response_type') !== 'code')
return displayError('unsupported_response_type');
let codeChallengeMethod = 'plain';
if(queryParams.has('code_challenge_method')) {
codeChallengeMethod = queryParams.get('code_challenge_method');
if(!['plain', 'S256'].includes(codeChallengeMethod))
return translateError('method');
}
if(!queryParams.has('code_challenge'))
return displayError('invalid_request', 'code_challenge must be specified.');
const codeChallenge = queryParams.get('code_challenge');
if(codeChallengeMethod === 'S256') {
if(codeChallenge.length !== 43)
return displayError('invalid_request', 'Specified code challenge is not a valid SHA-256 hash.');
} else {
if(codeChallenge.length < 43)
return displayError('invalid_request', 'Code challenge must be at least 43 characters long.');
if(codeChallenge.length > 128)
return displayError('invalid_request', 'Code challenge may not be longer than 128 characters.');
}
if(!queryParams.has('client_id'))
return displayError('invalid_request', 'client_id must be specified.');
const resolveParams = new URLSearchParams;
resolveParams.set('client', queryParams.get('client_id'));
if(redirectUriRaw !== undefined)
resolveParams.set('redirect', redirectUriRaw);
if(queryParams.has('scope')) {
scope = queryParams.get('scope');
resolveParams.set('scope', scope);
}
try {
const { body } = await $x.get(`/oauth2/resolve-authorise-app?${resolveParams}`, { authed: true, csrf: true, type: 'json' });
if(!body)
throw 'authorisation resolve failed';
if(typeof body.error === 'string')
return translateError(body.error, body);
const userHeader = new MszOAuth2UserHeader(body.user);
header.setElement(userHeader);
const verifyAuthsRequest = async () => {
const params = {
client: queryParams.get('client_id'),
cc: codeChallenge,
ccm: codeChallengeMethod,
};
if(redirectUriRaw !== undefined)
params.redirect = redirectUriRaw;
if(scope !== undefined)
params.scope = scope;
try {
const { body } = await $x.post('/oauth2/authorise', { authed: true, csrf: true, type: 'json' }, params);
if(!body)
throw 'authorisation failed';
if(typeof body.error === 'string')
return translateError(body.error, body);
const authoriseUri = new URL(body.redirect);
authoriseUri.searchParams.set('code', body.code);
if(state !== undefined)
authoriseUri.searchParams.set('state', state.toString());
window.location.assign(authoriseUri);
} catch(ex) {
console.error(ex);
translateError('authorise');
}
};
if(body.app.trusted && body.user.guise === undefined) {
if(userHeader)
userHeader.guiseVisible = false;
verifyAuthsRequest();
return;
}
eAuthsInfo.replaceWith(new MszOAuth2AppInfo(body.app).element);
eAuthsScope.replaceWith(new MszOAuth2AppScopeList(body.scope).element);
fAuths.onsubmit = ev => {
ev.preventDefault();
loading.visible = true;
fAuths.classList.add('hidden');
if(userHeader)
userHeader.guiseVisible = false;
if(ev.submitter?.value === 'yes')
verifyAuthsRequest();
else
displayError('access_denied');
};
loading.visible = false;
fAuths.classList.remove('hidden');
} catch(ex) {
console.error(ex);
displayError('server_error', 'Server was unable to respond to the client info request.');
}
};

24
assets/oauth2.js/csrf.js Normal file
View file

@ -0,0 +1,24 @@
#include utility.js
const MszCSRF = (() => {
let elem;
const getElement = () => {
if(elem === undefined)
elem = $q('meta[name="csrf-token"]');
return elem;
};
return {
get token() {
return getElement()?.content ?? '';
},
set token(token) {
if(typeof token !== 'string')
throw 'token must be a string';
const elem = getElement();
if(elem instanceof HTMLMetaElement)
elem.content = token;
},
};
})();

View file

@ -0,0 +1,57 @@
const MszOAuth2Header = function(element = '.js-oauth2-header', simpleElement = '.js-oauth2-header-simple') {
if(typeof element === 'string')
element = document.querySelector(element);
if(!(element instanceof HTMLElement))
throw 'element must be a valid query selector or an instance of HTMLElement';
if(typeof simpleElement === 'string')
simpleElement = element.querySelector(simpleElement);
const simpleElementIcon = simpleElement?.querySelector('.js-oauth2-header-simple-icon');
const simpleElementText = simpleElement?.querySelector('.js-oauth2-header-simple-text');
const hasSimpleElement = simpleElement instanceof HTMLElement;
const setSimpleVisible = state => {
if(hasSimpleElement)
simpleElement.classList.toggle('hidden', !state);
};
const setSimpleData = (icon, text) => {
if(hasSimpleElement) {
for(const className of simpleElementIcon.classList)
if(className.startsWith('oauth2-simplehead-icon--'))
simpleElementIcon.classList.remove(className);
simpleElementIcon.classList.add(`oauth2-simplehead-icon--${icon}`);
simpleElementText.textContent = text;
}
};
const removeElement = (forceSimple = true) => {
while(element.childElementCount > 1)
element.lastElementChild.remove();
if(typeof forceSimple === 'boolean')
setSimpleVisible(forceSimple);
};
return {
get element() { return element; },
get simpleVisible() { return hasSimpleElement && !simpleElement.classList.contains('hidden'); },
set simpleVisible(state) { setSimpleVisible(state); },
setSimpleData: setSimpleData,
setElement: elementInfo => {
removeElement(false);
if(elementInfo instanceof Element)
element.appendChild(elementInfo);
else if('element' in elementInfo)
element.appendChild(elementInfo.element);
else
throw 'elementInfo must be an instance of Element or contain an object with an element property';
},
removeElement: removeElement,
};
};

View file

@ -0,0 +1,53 @@
const MszOAuth2UserGuiseHeader = function(guise) {
const element = <div class="oauth2-userhead-guise">
<div class="oauth2-userhead-guise-avatar">
<div class="oauth2-userhead-guise-avatar-image">
<img src={guise.avatar_uri} alt=""/>
</div>
</div>
<div class="oauth2-userhead-guise-text">
<p>Are you <a href={guise.profile_uri} target="_blank" style={`color: ${guise.colour}`}>{guise.name}</a> and did you mean to use your own account?</p>
<p><a href={guise.revert_uri} target="_blank">Click here</a> and reload this page.</p>
</div>
</div>;
return {
get element() { return element; },
get visible() { return !element.classList.contains('hidden'); },
set visible(state) {
element.classList.toggle('hidden', !state);
},
};
};
const MszOAuth2UserHeader = function(user) {
const element = <div class="oauth2-userhead">
<div class="oauth2-userhead-main">
<div class="oauth2-userhead-main-avatar">
<div class="oauth2-userhead-main-avatar-image">
<img src={user.avatar_uri} alt=""/>
</div>
</div>
<div class="oauth2-userhead-main-name">
<a href={user.profile_uri} target="_blank">{user.name}</a>
</div>
</div>
</div>;
let guiseInfo;
if(user.guise) {
guiseInfo = new MszOAuth2UserGuiseHeader(user.guise);
element.appendChild(guiseInfo.element);
}
return {
get element() { return element; },
get guiseVisible() { return guiseInfo?.visible === true; },
set guiseVisible(state) {
if(guiseInfo !== undefined)
guiseInfo.visible = state;
},
};
};

View file

@ -0,0 +1,84 @@
const MszOAuth2LoadingIcon = function() {
const element = <div class="oauth2-loading-icon"/>;
for(let i = 0; i < 9; ++i)
element.appendChild(<div class="oauth2-loading-icon-block"/>);
// this is moderately cursed but it'll do
const blocks = [
element.children[3],
element.children[0],
element.children[1],
element.children[2],
element.children[5],
element.children[8],
element.children[7],
element.children[6],
];
let tsLastUpdate;
let counter = 0;
let playing = false;
const update = tsCurrent => {
try {
if(tsLastUpdate !== undefined && (tsCurrent - tsLastUpdate) < 50)
return;
tsLastUpdate = tsCurrent;
for(let i = 0; i < blocks.length; ++i)
blocks[(counter + i) % blocks.length].classList.toggle('oauth2-loading-icon-block-hidden', i < 3);
++counter;
} finally {
if(playing)
requestAnimationFrame(update);
}
};
const play = () => {
if(playing)
return;
playing = true;
requestAnimationFrame(update);
};
const pause = () => { playing = false; };
const stop = () => { pause(); counter = 0; };
const restart = () => { stop(); play(); };
return {
get element() { return element; },
get playing() { return playing; },
play: play,
pause: pause,
stop: stop,
restart: restart,
};
};
const MszOAuth2Loading = function(element) {
if(typeof element === 'string')
element = document.querySelector(element);
if(!(element instanceof HTMLElement))
element = <div class="oauth2-loading"/>;
if(!element.classList.contains('oauth2-loading'))
element.classList.add('oauth2-loading');
let icon;
if(element.childElementCount < 1) {
icon = new MszOAuth2LoadingIcon;
icon.play();
element.appendChild(<div class="oauth2-loading-frame">{icon}</div>);
}
return {
get element() { return element; },
get hasIcon() { return icon !== undefined; },
get icon() { return icon; },
get visible() { return !element.classList.contains('hidden'); },
set visible(state) { element.classList.toggle('hidden', !state); },
};
};

10
assets/oauth2.js/main.js Normal file
View file

@ -0,0 +1,10 @@
#include utility.js
#include authorise.js
#include verify.js
(() => {
if(location.pathname === '/oauth2/authorise' || location.pathname === '/oauth2/authorize')
MszOAuth2Authorise();
if(location.pathname === '/oauth2/verify')
MszOAuth2Verify();
})();

170
assets/oauth2.js/utility.js Normal file
View file

@ -0,0 +1,170 @@
const $i = document.getElementById.bind(document);
const $c = document.getElementsByClassName.bind(document);
const $q = document.querySelector.bind(document);
const $qa = document.querySelectorAll.bind(document);
const $t = document.createTextNode.bind(document);
const $r = function(element) {
if(element && element.parentNode)
element.parentNode.removeChild(element);
};
const $ri = function(name) {
$r($i(name));
};
const $rq = function(query) {
$r($q(query));
};
const $ib = function(ref, elem) {
ref.parentNode.insertBefore(elem, ref);
};
const $rc = function(element) {
while(element.lastChild)
element.removeChild(element.lastChild);
};
const $e = function(info, attrs, child, created) {
info = info || {};
if(typeof info === 'string') {
info = {tag: info};
if(attrs)
info.attrs = attrs;
if(child)
info.child = child;
if(created)
info.created = created;
}
const elem = document.createElement(info.tag || 'div');
if(info.attrs) {
const attrs = info.attrs;
for(let key in attrs) {
const attr = attrs[key];
if(attr === undefined || attr === null)
continue;
switch(typeof attr) {
case 'function':
if(key.substring(0, 2) === 'on')
key = key.substring(2).toLowerCase();
elem.addEventListener(key, attr);
break;
case 'object':
if(attr instanceof Array) {
if(key === 'class')
key = 'classList';
const prop = elem[key];
let addFunc = null;
if(prop instanceof Array)
addFunc = prop.push.bind(prop);
else if(prop instanceof DOMTokenList)
addFunc = prop.add.bind(prop);
if(addFunc !== null) {
for(let j = 0; j < attr.length; ++j)
addFunc(attr[j]);
} else {
if(key === 'classList')
key = 'class';
elem.setAttribute(key, attr.toString());
}
} else {
for(const attrKey in attr)
elem[key][attrKey] = attr[attrKey];
}
break;
case 'boolean':
if(attr)
elem.setAttribute(key, '');
break;
default:
if(key === 'className')
key = 'class';
elem.setAttribute(key, attr.toString());
break;
}
}
}
if(info.child) {
let children = info.child;
if(!Array.isArray(children))
children = [children];
for(const child of children) {
switch(typeof child) {
case 'string':
elem.appendChild(document.createTextNode(child));
break;
case 'object':
if(child instanceof Element) {
elem.appendChild(child);
} else if('element' in child) {
const childElem = child.element;
if(childElem instanceof Element)
elem.appendChild(childElem);
else
elem.appendChild($e(child));
} else if('getElement' in child) {
const childElem = child.getElement();
if(childElem instanceof Element)
elem.appendChild(childElem);
else
elem.appendChild($e(child));
} else {
elem.appendChild($e(child));
}
break;
default:
elem.appendChild(document.createTextNode(child.toString()));
break;
}
}
}
if(info.created)
info.created(elem);
return elem;
};
const $er = (type, props, ...children) => $e({ tag: type, attrs: props, child: children });
const $ar = function(array, index) {
array.splice(index, 1);
};
const $ari = function(array, item) {
let index;
while(array.length > 0 && (index = array.indexOf(item)) >= 0)
$ar(array, index);
};
const $arf = function(array, predicate) {
let index;
while(array.length > 0 && (index = array.findIndex(predicate)) >= 0)
$ar(array, index);
};
const $as = function(array) {
if(array.length < 2)
return;
for(let i = array.length - 1; i > 0; --i) {
let j = Math.floor(Math.random() * (i + 1)),
tmp = array[i];
array[i] = array[j];
array[j] = tmp;
}
};

178
assets/oauth2.js/verify.js Normal file
View file

@ -0,0 +1,178 @@
#include loading.jsx
#include xhr.js
#include app/info.jsx
#include app/scope.jsx
#include header/header.js
#include header/user.jsx
const MszOAuth2Verify = () => {
const queryParams = new URLSearchParams(window.location.search);
const loading = new MszOAuth2Loading('.js-loading');
const header = new MszOAuth2Header;
const fAuths = document.querySelector('.js-verify-authorise');
const eAuthsInfo = document.querySelector('.js-verify-authorise-info');
const eAuthsScope = document.querySelector('.js-verify-authorise-scope');
const rApproved = document.querySelector('.js-verify-approved');
const rDenied = document.querySelector('.js-verify-denied');
let userCode = '';
let userHeader;
const verifyAuthsRequest = async approve => {
try {
const { body } = await $x.post('/oauth2/verify', { authed: true, csrf: true, type: 'json' }, {
code: userCode,
approve: approve === true ? 'yes' : 'no',
});
if(!body)
throw 'response is empty';
if(typeof body.error === 'string') {
// TODO: nicer errors
if(body.error === 'auth')
alert('You are not logged in.');
else if(body.error === 'csrf')
alert('Request verification failed, please refresh and try again.');
else if(body.error === 'code')
alert('This code is not associated with any authorisation request.');
else if(body.error === 'approval')
alert('The authorisation request associated with this code is not pending approval.');
else if(body.error === 'expired')
alert('The authorisation request has expired, please restart the process from the application or device.');
else if(body.error === 'invalid')
alert('Invalid approval state specified.');
else if(body.error === 'scope') {
alert(`Requested scope "${body.scope}" is ${body.reason}.`);
loading.visible = false;
rDenied.classList.remove('hidden');
return;
} else
alert(`An unknown error occurred: ${body.error}`);
loading.visible = false;
fAuths.classList.remove('hidden');
return;
}
loading.visible = false;
if(body.approval === 'approved')
rApproved.classList.remove('hidden');
else
rDenied.classList.remove('hidden');
} catch(ex) {
alert('Request to verify endpoint failed. Please try again.');
loading.visible = false;
fAuths.classList.remove('hidden');
}
};
fAuths.onsubmit = ev => {
ev.preventDefault();
loading.visible = true;
fAuths.classList.add('hidden');
if(userHeader)
userHeader.guiseVisible = false;
verifyAuthsRequest(ev.submitter.value === 'yes');
};
const fCode = document.querySelector('.js-verify-code');
const eUserCode = fCode.elements.namedItem('code');
fCode.onsubmit = ev => {
ev.preventDefault();
loading.visible = true;
fCode.classList.add('hidden');
userCode = encodeURIComponent(eUserCode.value);
$x.get(`/oauth2/resolve-verify?code=${userCode}`, { authed: true, csrf: true, type: 'json' })
.then(result => {
const body = result.body;
if(!body) {
alert('Request to resolve endpoint failed. Please try again.');
loading.visible = false;
fCode.classList.remove('hidden');
return;
}
if(typeof body.error === 'string') {
// TODO: nicer errors
if(body.error === 'auth')
alert('You are not logged in.');
else if(body.error === 'csrf')
alert('Request verification failed, please refresh and try again.');
else if(body.error === 'code')
alert('This code is not associated with any authorisation request.');
else if(body.error === 'expired')
alert('The authorisation request has expired, please restart the process from the application or device.');
else if(body.error === 'approval')
alert('The authorisation request associated with this code is not pending approval.');
else if(body.error === 'scope') {
verifyAuthsRequest(false).finally(() => {
alert(`Requested scope "${body.scope}" is ${body.reason}.`);
});
return;
} else
alert(`An unknown error occurred: ${body.error}`);
loading.visible = false;
fCode.classList.remove('hidden');
return;
}
userCode = body.req.code;
userHeader = new MszOAuth2UserHeader(body.user);
header.setElement(userHeader);
if(body.app.trusted && body.user.guise === undefined) {
if(userHeader)
userHeader.guiseVisible = false;
verifyAuthsRequest(true);
return;
}
eAuthsInfo.replaceWith(new MszOAuth2AppInfo(body.app).element);
eAuthsScope.replaceWith(new MszOAuth2AppScopeList(body.scope).element);
loading.visible = false;
fAuths.classList.remove('hidden');
}).catch(() => {
alert('Request to resolve endpoint failed. Please try again.');
loading.visible = false;
fCode.classList.remove('hidden');
});
};
const validateCodeInput = () => {
// [A-Za-z0-8]{3}\-[A-Za-z0-8]{3}\-[A-Za-z0-8]{3}
// 0 -> O, 1 -> I, 8 -> B
const eCode = eUserCode.value;
return eCode.length > 0;
};
eUserCode.oninput = () => {
validateCodeInput();
console.warn(eUserCode.value);
};
if(queryParams.has('code') && eUserCode.value === '')
eUserCode.value = queryParams.get('code');
if(validateCodeInput()) {
fCode.requestSubmit();
} else {
loading.visible = false;
fCode.classList.remove('hidden');
}
};

117
assets/oauth2.js/xhr.js Normal file
View file

@ -0,0 +1,117 @@
#include csrf.js
const $x = (function() {
const send = function(method, url, options, body) {
if(options === undefined)
options = {};
else if(typeof options !== 'object')
throw 'options must be undefined or an object';
Object.freeze(options);
const xhr = new XMLHttpRequest;
const requestHeaders = new Map;
if('headers' in options && typeof options.headers === 'object')
for(const name in options.headers)
if(options.headers.hasOwnProperty(name))
requestHeaders.set(name.toLowerCase(), options.headers[name]);
if(options.csrf)
requestHeaders.set('x-csrf-token', MszCSRF.token);
if(typeof options.download === 'function') {
xhr.onloadstart = ev => options.download(ev);
xhr.onprogress = ev => options.download(ev);
xhr.onloadend = ev => options.download(ev);
}
if(typeof options.upload === 'function') {
xhr.upload.onloadstart = ev => options.upload(ev);
xhr.upload.onprogress = ev => options.upload(ev);
xhr.upload.onloadend = ev => options.upload(ev);
}
if(options.authed)
xhr.withCredentials = true;
if(typeof options.timeout === 'number')
xhr.timeout = options.timeout;
if(typeof options.type === 'string')
xhr.responseType = options.type;
if(typeof options.abort === 'function')
options.abort(() => xhr.abort());
if(typeof options.xhr === 'function')
options.xhr(() => xhr);
if(typeof body === 'object') {
if(body instanceof URLSearchParams) {
requestHeaders.set('content-type', 'application/x-www-form-urlencoded');
} else if(body instanceof FormData) {
// content-type is implicitly set
} else if(body instanceof Blob || body instanceof ArrayBuffer || body instanceof DataView) {
if(!requestHeaders.has('content-type'))
requestHeaders.set('content-type', 'application/octet-stream');
} else if(!requestHeaders.has('content-type')) {
const bodyParts = [];
for(const name in body)
if(body.hasOwnProperty(name))
bodyParts.push(encodeURIComponent(name) + '=' + encodeURIComponent(body[name]));
body = bodyParts.join('&');
requestHeaders.set('content-type', 'application/x-www-form-urlencoded');
}
}
return new Promise((resolve, reject) => {
xhr.onload = ev => {
const headers = (headersString => {
const headers = new Map;
const raw = headersString.trim().split(/[\r\n]+/);
for(const name in raw)
if(raw.hasOwnProperty(name)) {
const parts = raw[name].split(': ');
headers.set(parts.shift(), parts.join(': '));
}
return headers;
})(xhr.getAllResponseHeaders());
if(options.csrf && headers.has('x-csrf-token'))
MszCSRF.token = headers.get('x-csrf-token');
resolve({
get ev() { return ev; },
get xhr() { return xhr; },
get status() { return xhr.status; },
get headers() { return headers; },
get body() { return xhr.response; },
get text() { return xhr.responseText; },
});
};
xhr.onerror = ev => reject({
xhr: xhr,
ev: ev,
});
xhr.open(method, url);
for(const [name, value] of requestHeaders)
xhr.setRequestHeader(name, value);
xhr.send(body);
});
};
return {
send: send,
get: (url, options, body) => send('GET', url, options, body),
post: (url, options, body) => send('POST', url, options, body),
delete: (url, options, body) => send('DELETE', url, options, body),
patch: (url, options, body) => send('PATCH', url, options, body),
put: (url, options, body) => send('PUT', url, options, body),
};
})();

View file

@ -18,12 +18,14 @@ const fs = require('fs');
const tasks = {
js: [
{ source: 'misuzu.js', target: '/assets', name: 'misuzu.{hash}.js', },
{ source: 'oauth2.js', target: '/assets', name: 'oauth2.{hash}.js', },
{ source: 'redir-bsky.js', target: '/assets', name: 'redir-bsky.{hash}.js', },
{ source: 'redir-fedi.js', target: '/assets', name: 'redir-fedi.{hash}.js', },
],
css: [
{ source: 'errors.css', target: '/', name: 'errors.css', },
{ source: 'misuzu.css', target: '/assets', name: 'misuzu.{hash}.css', },
{ source: 'oauth2.css', target: '/assets', name: 'oauth2.{hash}.css', },
],
twig: [
{ source: 'errors/400', target: '/', name: 'error-400.html', },

View file

@ -7,5 +7,5 @@ database:dsn mariadb://<user>:<pass>@<host>/<name>?charset=utf8mb4
;sentry:tracesRate 1.0
;sentry:profilesRate 1.0
domain:localhost main redirect
domain:localhost main redirect id
domain:localhost:redirect:path /go

View file

@ -0,0 +1,54 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
final class CreateAppsTables_20250201_181944 implements DbMigration {
public function migrate(DbConnection $conn): void {
$conn->execute(<<<SQL
CREATE TABLE msz_apps (
app_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT(10) UNSIGNED NULL DEFAULT NULL,
app_name VARCHAR(64) NOT NULL COLLATE 'utf8mb4_unicode_520_ci',
app_summary VARCHAR(255) NOT NULL COLLATE 'utf8mb4_bin',
app_website VARCHAR(255) NOT NULL COLLATE 'utf8mb4_bin',
app_type ENUM('public','confidential','trusted') NOT NULL COLLATE 'ascii_general_ci',
app_access_lifetime INT(10) UNSIGNED NULL DEFAULT NULL,
app_refresh_lifetime INT(10) UNSIGNED NULL DEFAULT NULL,
app_client_id CHAR(20) NOT NULL COLLATE 'ascii_bin',
app_client_secret VARCHAR(255) NOT NULL COLLATE 'ascii_bin',
app_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
app_updated TIMESTAMP NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
app_deleted TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (app_id),
UNIQUE INDEX apps_client_id_unique (app_client_id),
UNIQUE INDEX apps_name_unique (app_name),
INDEX apps_user_foreign (user_id),
INDEX apps_created_index (app_created),
INDEX apps_deleted_index (app_deleted),
CONSTRAINT apps_user_foreign
FOREIGN KEY (user_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE
) COLLATE='utf8mb4_bin' ENGINE=InnoDB;
SQL);
$conn->execute(<<<SQL
CREATE TABLE msz_apps_uris (
uri_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
app_id INT(10) UNSIGNED NOT NULL,
uri_string VARCHAR(255) NOT NULL COLLATE 'ascii_bin',
uri_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (uri_id),
INDEX apps_uris_app_foreign (app_id),
INDEX apps_uris_lookup_index (uri_id, uri_string),
INDEX apps_uri_created_index (uri_created),
CONSTRAINT apps_uris_app_foreign
FOREIGN KEY (app_id)
REFERENCES msz_apps (app_id)
ON UPDATE CASCADE
ON DELETE CASCADE
) COLLATE='utf8mb4_bin' ENGINE=InnoDB;
SQL);
}
}

View file

@ -0,0 +1,43 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
final class CreateScopesTables_20250201_182753 implements DbMigration {
public function migrate(DbConnection $conn): void {
$conn->execute(<<<SQL
CREATE TABLE msz_scopes (
scope_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
scope_string VARCHAR(50) NOT NULL COLLATE 'ascii_bin',
scope_restricted TINYINT(3) UNSIGNED NOT NULL,
scope_summary VARCHAR(255) NOT NULL DEFAULT '' COLLATE 'utf8mb4_unicode_520_ci',
scope_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
scope_deprecated TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (scope_id),
UNIQUE INDEX scopes_string_unique (scope_string),
INDEX scopes_created_index (scope_created),
INDEX scopes_deprecated_index (scope_deprecated)
) COLLATE=utf8mb4_bin ENGINE=InnoDB;
SQL);
$conn->execute(<<<SQL
CREATE TABLE msz_apps_scopes (
app_id INT(10) UNSIGNED NOT NULL,
scope_id INT(10) UNSIGNED NOT NULL,
scope_allowed TINYINT(3) UNSIGNED NOT NULL,
PRIMARY KEY (app_id, scope_id),
INDEX apps_scopes_app_foreign (app_id),
INDEX apps_scopes_scope_foreign (scope_id),
CONSTRAINT apps_scopes_app_foreign
FOREIGN KEY (app_id)
REFERENCES msz_apps (app_id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT apps_scopes_scope_foreign
FOREIGN KEY (scope_id)
REFERENCES msz_scopes (scope_id)
ON UPDATE CASCADE
ON DELETE CASCADE
) COLLATE=utf8mb4_bin ENGINE=InnoDB;
SQL);
}
}

View file

@ -0,0 +1,136 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
final class CreateOauthTables_20250201_183150 implements DbMigration {
public function migrate(DbConnection $conn): void {
$conn->execute(<<<SQL
CREATE TABLE msz_oauth2_authorise (
auth_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
app_id INT(10) UNSIGNED NOT NULL,
user_id INT(10) UNSIGNED NOT NULL,
uri_id INT(10) UNSIGNED NOT NULL,
auth_challenge_code VARCHAR(128) NOT NULL COLLATE 'ascii_bin',
auth_challenge_method ENUM('plain','S256') NOT NULL DEFAULT 'plain' COLLATE 'ascii_bin',
auth_scope TEXT NOT NULL COLLATE 'ascii_bin',
auth_code CHAR(60) NOT NULL COLLATE 'ascii_bin',
auth_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
auth_expires TIMESTAMP NOT NULL DEFAULT (current_timestamp() + interval 10 minute),
PRIMARY KEY (auth_id),
UNIQUE INDEX oauth2_authorise_code_unique (auth_code),
INDEX oauth2_authorise_app_foreign (app_id),
INDEX oauth2_authorise_uri_foreign (uri_id),
INDEX oauth2_authorise_user_foreign (user_id),
INDEX oauth2_authorise_expires_index (auth_expires),
CONSTRAINT oauth2_authorise_app_foreign
FOREIGN KEY (app_id)
REFERENCES msz_apps (app_id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT oauth2_authorise_uri_foreign
FOREIGN KEY (uri_id)
REFERENCES msz_apps_uris (uri_id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT oauth2_authorise_user_foreign
FOREIGN KEY (user_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE
) COLLATE='utf8mb4_bin' ENGINE=InnoDB;
SQL);
$conn->execute(<<<SQL
CREATE TABLE msz_oauth2_device (
dev_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
app_id INT(10) UNSIGNED NOT NULL,
user_id INT(10) UNSIGNED NULL DEFAULT NULL,
dev_code CHAR(60) NOT NULL COLLATE 'ascii_bin',
dev_user_code CHAR(9) NOT NULL COLLATE 'ascii_general_ci',
dev_interval TINYINT(3) UNSIGNED NOT NULL DEFAULT '5',
dev_polled TIMESTAMP NOT NULL DEFAULT current_timestamp(),
dev_scope TEXT NOT NULL COLLATE 'ascii_bin',
dev_approval ENUM('pending','approved','denied') NOT NULL DEFAULT 'pending' COLLATE 'ascii_general_ci',
dev_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
dev_expires TIMESTAMP NOT NULL DEFAULT (current_timestamp() + interval 10 minute),
PRIMARY KEY (dev_id),
UNIQUE INDEX oauth2_device_user_code_unique (dev_user_code),
UNIQUE INDEX oauth2_device_code_unique (dev_code),
INDEX oauth2_device_expires_index (dev_expires),
INDEX oauth2_device_app_foreign (app_id),
INDEX oauth2_device_user_foreign (user_id),
CONSTRAINT oauth2_device_app_foreign
FOREIGN KEY (app_id)
REFERENCES msz_apps (app_id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT oauth2_device_user_foreign
FOREIGN KEY (user_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE
) COLLATE='utf8mb4_bin' ENGINE=InnoDB;
SQL);
$conn->execute(<<<SQL
CREATE TABLE msz_oauth2_access (
acc_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
app_id INT(10) UNSIGNED NOT NULL,
user_id INT(10) UNSIGNED NULL DEFAULT NULL,
acc_token VARCHAR(255) NOT NULL COLLATE 'ascii_bin',
acc_scope TEXT NOT NULL COLLATE 'ascii_bin',
acc_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
acc_expires TIMESTAMP NOT NULL DEFAULT (current_timestamp() + interval 1 hour),
PRIMARY KEY (acc_id),
UNIQUE INDEX oauth2_access_token_unique (acc_token),
INDEX oauth2_access_user_foreign (user_id),
INDEX oauth2_access_app_foreign (app_id),
INDEX oauth2_access_expires_index (acc_expires),
CONSTRAINT oauth2_access_app_foreign
FOREIGN KEY (app_id)
REFERENCES msz_apps (app_id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT oauth2_access_user_foreign
FOREIGN KEY (user_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE
) COLLATE='utf8mb4_bin' ENGINE=InnoDB;
SQL);
$conn->execute(<<<SQL
CREATE TABLE msz_oauth2_refresh (
ref_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
app_id INT(10) UNSIGNED NOT NULL,
user_id INT(10) UNSIGNED NULL DEFAULT NULL,
acc_id INT(10) UNSIGNED NULL DEFAULT NULL,
ref_token VARCHAR(255) NOT NULL COLLATE 'ascii_bin',
ref_scope TEXT NOT NULL COLLATE 'ascii_bin',
ref_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
ref_expires TIMESTAMP NOT NULL DEFAULT (current_timestamp() + interval 1 month),
PRIMARY KEY (ref_id),
UNIQUE INDEX oauth2_refresh_token_unique (ref_token),
UNIQUE INDEX oauth2_refresh_access_foreign (acc_id),
INDEX oauth2_refresh_expires_index (ref_expires),
INDEX oauth2_refresh_app_foreign (app_id),
INDEX oauth2_refresh_user_foreign (user_id),
CONSTRAINT oauth2_refresh_access_foreign
FOREIGN KEY (acc_id)
REFERENCES msz_oauth2_access (acc_id)
ON UPDATE CASCADE
ON DELETE SET NULL,
CONSTRAINT oauth2_refresh_app_foreign
FOREIGN KEY (app_id)
REFERENCES msz_apps (app_id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT oauth2_refresh_user_foreign
FOREIGN KEY (user_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE
) COLLATE='utf8mb4_bin' ENGINE=InnoDB;
SQL);
}
}

View file

@ -7,7 +7,7 @@ use Misuzu\Auth\AuthTokenCookie;
if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
die('Script must be called through the Misuzu route dispatcher.');
if($msz->authInfo->isLoggedIn) {
if($msz->authInfo->loggedIn) {
Tools::redirect($msz->urls->format('index'));
return;
}

View file

@ -6,7 +6,7 @@ use Misuzu\Auth\AuthTokenCookie;
if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
die('Script must be called through the Misuzu route dispatcher.');
if($msz->authInfo->isLoggedIn) {
if($msz->authInfo->loggedIn) {
if(!CSRF::validateRequest()) {
Template::render('auth.logout');
return;

View file

@ -7,7 +7,7 @@ use Misuzu\Users\User;
if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
die('Script must be called through the Misuzu route dispatcher.');
if($msz->authInfo->isLoggedIn) {
if($msz->authInfo->loggedIn) {
Tools::redirect($msz->urls->format('settings-account'));
return;
}

View file

@ -7,7 +7,7 @@ use Misuzu\Users\User;
if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
die('Script must be called through the Misuzu route dispatcher.');
if($msz->authInfo->isLoggedIn) {
if($msz->authInfo->loggedIn) {
Tools::redirect($msz->urls->format('index'));
return;
}

View file

@ -8,7 +8,7 @@ use Misuzu\Auth\AuthTokenCookie;
if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
die('Script must be called through the Misuzu route dispatcher.');
if($msz->authInfo->isLoggedIn) {
if($msz->authInfo->loggedIn) {
Tools::redirect($msz->urls->format('index'));
return;
}

View file

@ -15,7 +15,7 @@ if(!Tools::isLocalURL($redirect))
if(!CSRF::validateRequest())
Template::displayInfo("Couldn't verify this request, please refresh the page and try again.", 403);
if(!$msz->authInfo->isLoggedIn)
if(!$msz->authInfo->loggedIn)
Template::displayInfo('You must be logged in to manage comments.', 403);
if($msz->usersCtx->hasActiveBan($msz->authInfo->userInfo))

View file

@ -11,7 +11,7 @@ use Carbon\CarbonImmutable;
if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
die('Script must be called through the Misuzu route dispatcher.');
if(!$msz->authInfo->isLoggedIn)
if(!$msz->authInfo->loggedIn)
Template::throwError(401);
$currentUser = $msz->authInfo->userInfo;

View file

@ -11,7 +11,7 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
die('Script must be called through the Misuzu route dispatcher.');
$viewerPerms = $msz->authInfo->getPerms('user');
if(!$msz->authInfo->isLoggedIn)
if(!$msz->authInfo->loggedIn)
Template::throwError(403);
$currentUser = $msz->authInfo->userInfo;

View file

@ -6,7 +6,7 @@ use RuntimeException;
if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
die('Script must be called through the Misuzu route dispatcher.');
if(!$msz->authInfo->isLoggedIn)
if(!$msz->authInfo->loggedIn)
Template::throwError(403);
// TODO: restore forum-topics and forum-posts orderings

View file

@ -9,7 +9,7 @@ use Misuzu\Comments\CommentsCategory;
if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
die('Script must be called through the Misuzu route dispatcher.');
if(!$msz->authInfo->isLoggedIn)
if(!$msz->authInfo->loggedIn)
Template::throwError(403);
$searchQuery = !empty($_GET['q']) && is_string($_GET['q']) ? $_GET['q'] : '';

View file

@ -9,7 +9,7 @@ use chillerlan\QRCode\QROptions;
if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
die('Script must be called through the Misuzu route dispatcher.');
if(!$msz->authInfo->isLoggedIn)
if(!$msz->authInfo->loggedIn)
Template::throwError(401);
$errors = [];

View file

@ -8,7 +8,7 @@ use Misuzu\Users\UserInfo;
if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
die('Script must be called through the Misuzu route dispatcher.');
if(!$msz->authInfo->isLoggedIn)
if(!$msz->authInfo->loggedIn)
Template::throwError(401);
$dbConn = $msz->dbConn;

View file

@ -6,7 +6,7 @@ use RuntimeException;
if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
die('Script must be called through the Misuzu route dispatcher.');
if(!$msz->authInfo->isLoggedIn)
if(!$msz->authInfo->loggedIn)
Template::throwError(401);
$errors = [];

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M256 48a208 208 0 1 1 0 416 208 208 0 1 1 0-416zm0 464A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-111 111-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l64 64c9.4 9.4 24.6 9.4 33.9 0L369 209z"/></svg>

After

(image error) Size: 471 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>

After

(image error) Size: 424 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-112c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>

After

(image error) Size: 421 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM169.8 165.3c7.9-22.3 29.1-37.3 52.8-37.3l58.3 0c34.9 0 63.1 28.3 63.1 63.1c0 22.6-12.1 43.5-31.7 54.8L280 264.4c-.2 13-10.9 23.6-24 23.6c-13.3 0-24-10.7-24-24l0-13.5c0-8.6 4.6-16.5 12.1-20.8l44.3-25.4c4.7-2.7 7.6-7.7 7.6-13.1c0-8.4-6.8-15.1-15.1-15.1l-58.3 0c-3.4 0-6.4 2.1-7.5 5.3l-.4 1.2c-4.4 12.5-18.2 19-30.6 14.6s-19-18.2-14.6-30.6l.4-1.2zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>

After

(image error) Size: 669 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M464 256A208 208 0 1 0 48 256a208 208 0 1 0 416 0zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256z"/></svg>

After

(image error) Size: 328 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM175 175c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z"/></svg>

After

(image error) Size: 511 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M8 256a56 56 0 1 1 112 0A56 56 0 1 1 8 256zm160 0a56 56 0 1 1 112 0 56 56 0 1 1 -112 0zm216-56a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/></svg>

After

(image error) Size: 362 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M96 64c0-17.7-14.3-32-32-32S32 46.3 32 64l0 256c0 17.7 14.3 32 32 32s32-14.3 32-32L96 64zM64 480a40 40 0 1 0 0-80 40 40 0 1 0 0 80z"/></svg>

After

(image error) Size: 362 B

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1000 1000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g id="g285"><path id="rect18" d="M912.855,608.974l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect16" d="M854.364,442.816l-86.145,321.498l-57.813,-15.49l86.145,-321.499l57.813,15.491Z"/><path id="rect14" d="M772.856,362.559l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect12" d="M676.004,339.569l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect10" d="M571.479,345.212l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect8" d="M466.954,350.855l-86.145,321.498l-57.813,-15.491l86.146,-321.498l57.812,15.491Z"/><path id="rect6" d="M370.102,327.865l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect4" d="M288.594,247.608l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect2" d="M230.103,81.45l-86.145,321.498l-57.813,-15.49l86.145,-321.499l57.813,15.491Z"/></g></svg>

After

(image error) Size: 1.4 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M352 256c0 22.2-1.2 43.6-3.3 64l-185.3 0c-2.2-20.4-3.3-41.8-3.3-64s1.2-43.6 3.3-64l185.3 0c2.2 20.4 3.3 41.8 3.3 64zm28.8-64l123.1 0c5.3 20.5 8.1 41.9 8.1 64s-2.8 43.5-8.1 64l-123.1 0c2.1-20.6 3.2-42 3.2-64s-1.1-43.4-3.2-64zm112.6-32l-116.7 0c-10-63.9-29.8-117.4-55.3-151.6c78.3 20.7 142 77.5 171.9 151.6zm-149.1 0l-176.6 0c6.1-36.4 15.5-68.6 27-94.7c10.5-23.6 22.2-40.7 33.5-51.5C239.4 3.2 248.7 0 256 0s16.6 3.2 27.8 13.8c11.3 10.8 23 27.9 33.5 51.5c11.6 26 20.9 58.2 27 94.7zm-209 0L18.6 160C48.6 85.9 112.2 29.1 190.6 8.4C165.1 42.6 145.3 96.1 135.3 160zM8.1 192l123.1 0c-2.1 20.6-3.2 42-3.2 64s1.1 43.4 3.2 64L8.1 320C2.8 299.5 0 278.1 0 256s2.8-43.5 8.1-64zM194.7 446.6c-11.6-26-20.9-58.2-27-94.6l176.6 0c-6.1 36.4-15.5 68.6-27 94.6c-10.5 23.6-22.2 40.7-33.5 51.5C272.6 508.8 263.3 512 256 512s-16.6-3.2-27.8-13.8c-11.3-10.8-23-27.9-33.5-51.5zM135.3 352c10 63.9 29.8 117.4 55.3 151.6C112.2 482.9 48.6 426.1 18.6 352l116.7 0zm358.1 0c-30 74.1-93.6 130.9-171.9 151.6c25.5-34.2 45.2-87.7 55.3-151.6l116.7 0z"/></svg>

After

(image error) Size: 1.2 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M16 64C16 28.7 44.7 0 80 0L304 0c35.3 0 64 28.7 64 64l0 384c0 35.3-28.7 64-64 64L80 512c-35.3 0-64-28.7-64-64L16 64zM144 448c0 8.8 7.2 16 16 16l64 0c8.8 0 16-7.2 16-16s-7.2-16-16-16l-64 0c-8.8 0-16 7.2-16 16zM304 64L80 64l0 320 224 0 0-320z"/></svg>

After

(image error) Size: 471 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M224 256A128 128 0 1 0 224 0a128 128 0 1 0 0 256zm-45.7 48C79.8 304 0 383.8 0 482.3C0 498.7 13.3 512 29.7 512l362.8 0c-5.4-9.4-8.6-20.3-8.6-32l0-128c0-2.1 .1-4.2 .3-6.3c-31-26-71-41.7-114.6-41.7l-91.4 0zM528 240c17.7 0 32 14.3 32 32l0 48-64 0 0-48c0-17.7 14.3-32 32-32zm-80 32l0 48c-17.7 0-32 14.3-32 32l0 128c0 17.7 14.3 32 32 32l160 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32l0-48c0-44.2-35.8-80-80-80s-80 35.8-80 80z"/></svg>

After

(image error) Size: 657 B

View file

@ -121,7 +121,7 @@ $msz->authInfo->setInfo($tokenInfo, $userInfo, $sessionInfo, $userInfoReal);
CSRF::init(
$msz->config->getString('csrf.secret', 'soup'),
($msz->authInfo->isLoggedIn ? $sessionInfo->token : $remoteAddr)
($msz->authInfo->loggedIn ? $sessionInfo->token : $remoteAddr)
);
// order for these two currently matters i think: it shouldn't.

View file

@ -33,6 +33,12 @@ class XrpcClient {
curl_close($this->handle);
}
/**
* @param array<string, scalar> $headers
* @param array<string, scalar> $params
* @param mixed[]|object|string|null $data
* @return object{ headers: array<string, string>, data: mixed }
*/
private function request(
string $method,
string $nsid,
@ -135,6 +141,11 @@ class XrpcClient {
];
}
/**
* @param array<string, scalar> $headers
* @param array<string, scalar> $params
* @return object{ headers: array<string, string>, data: mixed }
*/
public function query(
string $nsid,
array $headers = [],
@ -143,6 +154,12 @@ class XrpcClient {
return $this->request('GET', $nsid, $headers, $params, null);
}
/**
* @param array<string, scalar> $headers
* @param array<string, scalar> $params
* @param mixed[]|object|string|null $data
* @return object{ headers: array<string, string>, data: mixed }
*/
public function call(
string $nsid,
array $headers = [],

87
src/Apps/AppInfo.php Normal file
View file

@ -0,0 +1,87 @@
<?php
namespace Misuzu\Apps;
use Carbon\CarbonImmutable;
use Index\Db\DbResult;
class AppInfo {
public function __construct(
public private(set) string $id,
public private(set) ?string $userId,
public private(set) string $name,
public private(set) string $summary,
public private(set) string $website,
public private(set) AppType $type,
public private(set) ?int $accessTokenLifetime,
public private(set) ?int $refreshTokenLifetime,
public private(set) string $clientId,
#[\SensitiveParameter] private string $clientSecret,
public private(set) int $createdTime,
public private(set) int $updatedTime,
public private(set) ?int $deletedTime
) {}
public static function fromResult(DbResult $result): AppInfo {
return new AppInfo(
id: $result->getString(0),
userId: $result->getStringOrNull(1),
name: $result->getString(2),
summary: $result->getString(3),
website: $result->getString(4),
type: AppType::tryFrom($result->getString(5)) ?? AppType::Public,
accessTokenLifetime: $result->getIntegerOrNull(6),
refreshTokenLifetime: $result->getIntegerOrNull(7),
clientId: $result->getString(8),
clientSecret: $result->getString(9),
createdTime: $result->getInteger(10),
updatedTime: $result->getInteger(11),
deletedTime: $result->getIntegerOrNull(12),
);
}
public string $websiteForDisplay {
get {
$website = $this->website;
if(str_starts_with($website, 'https://'))
$website = substr($website, 8);
return rtrim($website, '/');
}
}
public bool $confidential {
get => $this->type === AppType::Confidential || $this->type === AppType::Trusted;
}
public bool $trusted {
get => $this->type === AppType::Trusted;
}
public bool $issueRefreshToken {
get => $this->refreshTokenLifetime === null ? $this->confidential : $this->refreshTokenLifetime > 0;
}
public function verifyClientSecret(#[\SensitiveParameter] string $input): bool {
return $this->confidential && password_verify($input, $this->clientSecret);
}
public CarbonImmutable $createdAt {
get => CarbonImmutable::createFromTimestampUTC($this->createdTime);
}
public bool $updated {
get => $this->updatedTime > $this->createdTime;
}
public CarbonImmutable $updatedAt {
get => CarbonImmutable::createFromTimestampUTC($this->updatedTime);
}
public bool $deleted {
get => $this->deletedTime !== null;
}
public ?CarbonImmutable $deletedAt {
get => $this->deletedTime === null ? null : CarbonImmutable::createFromTimestampUTC($this->deletedTime);
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace Misuzu\Apps;
class AppScopesInfo {
/**
* @param string[] $allowed
* @param string[] $denied
*/
public function __construct(
public private(set) array $allowed,
public private(set) array $denied
) {}
public function isAllowed(string $scope, bool $requiresAllow): bool {
if(in_array($scope, $this->denied))
return false;
if($requiresAllow && !in_array($scope, $this->allowed))
return false;
return true;
}
}

8
src/Apps/AppType.php Normal file
View file

@ -0,0 +1,8 @@
<?php
namespace Misuzu\Apps;
enum AppType: string {
case Public = 'public';
case Confidential = 'confidential';
case Trusted = 'trusted';
}

31
src/Apps/AppUriInfo.php Normal file
View file

@ -0,0 +1,31 @@
<?php
namespace Misuzu\Apps;
use Carbon\CarbonImmutable;
use Index\Db\DbResult;
class AppUriInfo {
public function __construct(
public private(set) string $id,
public private(set) string $appId,
public private(set) string $string,
public private(set) int $createdTime
) {}
public static function fromResult(DbResult $result): AppUriInfo {
return new AppUriInfo(
id: $result->getString(0),
appId: $result->getString(1),
string: $result->getString(2),
createdTime: $result->getInteger(3)
);
}
public function compareString(string $other): int {
return strcmp($this->string, $other);
}
public CarbonImmutable $createdAt {
get => CarbonImmutable::createFromTimestampUTC($this->createdTime);
}
}

71
src/Apps/AppsContext.php Normal file
View file

@ -0,0 +1,71 @@
<?php
namespace Misuzu\Apps;
use RuntimeException;
use Index\Db\DbConnection;
class AppsContext {
public private(set) AppsData $apps;
public private(set) ScopesData $scopes;
public function __construct(DbConnection $dbConn) {
$this->apps = new AppsData($dbConn);
$this->scopes = new ScopesData($dbConn);
}
/**
* @return array<string, string>
*/
public function handleScopeString(
AppInfo|string $appInfo,
string $scope,
bool $allowDeprecated = false,
bool $sort = true,
bool $breakOnFail = true
): array {
return $this->handleScopes($appInfo, explode(' ', $scope), $allowDeprecated, $sort, $breakOnFail);
}
/**
* @param string[] $strings
* @return array<string, string|ScopeInfo>
*/
public function handleScopes(
AppInfo|string $appInfo,
array $strings,
bool $allowDeprecated = false,
bool $sort = true,
bool $breakOnFail = true
): array {
if(is_string($appInfo))
$appInfo = $this->apps->getAppInfo(appId: $appInfo, deleted: false);
$infos = [];
foreach($strings as $string) {
try {
$scopeInfo = $this->scopes->getScopeInfo($string, ScopeInfoGetField::String);
if(!$allowDeprecated && $scopeInfo->deprecated) {
$infos[$string] = 'deprecated';
if($breakOnFail) break; else continue;
}
if(!$this->apps->isAppScopeAllowed($appInfo, $scopeInfo)) {
$infos[$string] = 'restricted';
if($breakOnFail) break; else continue;
}
$infos[$string] = $scopeInfo;
} catch(RuntimeException $ex) {
$infos[$string] = 'unknown';
if($breakOnFail) break; else continue;
}
}
if($sort)
ksort($infos, SORT_STRING);
return $infos;
}
}

213
src/Apps/AppsData.php Normal file
View file

@ -0,0 +1,213 @@
<?php
namespace Misuzu\Apps;
use stdClass;
use InvalidArgumentException;
use RuntimeException;
use Index\XString;
use Index\Db\{DbConnection,DbStatementCache};
use Misuzu\Users\UserInfo;
class AppsData {
private DbStatementCache $cache;
public function __construct(DbConnection $dbConn) {
$this->cache = new DbStatementCache($dbConn);
}
public function getAppInfo(
?string $appId = null,
?string $clientId = null,
?bool $deleted = null
): AppInfo {
$hasAppId = $appId !== null;
$hasClientId = $clientId !== null;
$hasDeleted = $deleted !== null;
if($hasAppId === $hasClientId)
throw new InvalidArgumentException('you must specify either $appId or $clientId');
$values = [];
$query = <<<SQL
SELECT app_id, user_id, app_name, app_summary, app_website, app_type,
app_access_lifetime, app_refresh_lifetime, app_client_id, app_client_secret,
UNIX_TIMESTAMP(app_created), UNIX_TIMESTAMP(app_updated), UNIX_TIMESTAMP(app_deleted)
FROM msz_apps
SQL;
$query .= sprintf(' WHERE %s = ?', $hasAppId ? 'app_id' : 'app_client_id');
if($hasDeleted)
$query .= sprintf(' AND app_deleted %s NULL', $deleted ? 'IS NOT' : 'IS');
$stmt = $this->cache->get($query);
$stmt->nextParameter($hasAppId ? $appId : $clientId);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('Application not found.');
return AppInfo::fromResult($result);
}
public const int CLIENT_ID_LENGTH = 20;
public const string CLIENT_SECRET_ALGO = PASSWORD_ARGON2ID;
public function createApp(
string $name,
AppType $type,
UserInfo|string|null $userInfo = null,
string $summary = '',
string $website = '',
?string $clientId = null,
#[\SensitiveParameter] ?string $clientSecret = null,
?int $accessLifetime = null,
?int $refreshLifetime = null
): AppInfo {
if(trim($name) === '')
throw new InvalidArgumentException('$name may not be empty');
if($clientId === null)
$clientId = XString::random(self::CLIENT_ID_LENGTH);
elseif(trim($clientId) === '')
throw new InvalidArgumentException('$clientId may not be empty');
if($accessLifetime !== null && $accessLifetime < 1)
throw new InvalidArgumentException('$accessLifetime must be null or greater than zero');
if($refreshLifetime !== null && $refreshLifetime < 0)
throw new InvalidArgumentException('$refreshLifetime must be null or a positive integer');
$summary = trim($summary);
$website = trim($website);
$clientSecret = $clientSecret === null ? '' : password_hash($clientSecret, self::CLIENT_SECRET_ALGO);
if($type !== AppType::Public && $clientSecret === '')
throw new InvalidArgumentException('$clientSecret must be specified for confidential clients');
$stmt = $this->cache->get(<<<SQL
INSERT INTO msz_apps (
user_id, app_name, app_summary, app_website, app_type,
app_access_lifetime, app_refresh_lifetime,
app_client_id, app_client_secret
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
SQL);
$stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
$stmt->nextParameter($name);
$stmt->nextParameter($summary);
$stmt->nextParameter($website);
$stmt->nextParameter($type);
$stmt->nextParameter($accessLifetime);
$stmt->nextParameter($refreshLifetime);
$stmt->nextParameter($clientId);
$stmt->nextParameter($clientSecret);
$stmt->execute();
return $this->getAppInfo(appId: (string)$stmt->lastInsertId);
}
public function countAppUris(AppInfo|string $appInfo): int {
$stmt = $this->cache->get('SELECT COUNT(*) FROM msz_apps_uris WHERE app_id = ?');
$stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
$stmt->execute();
$result = $stmt->getResult();
return $result->next() ? $result->getInteger(0) : 0;
}
/** @return iterable<AppUriInfo> */
public function getAppUriInfos(AppInfo|string $appInfo): iterable {
$stmt = $this->cache->get(<<<SQL
SELECT uri_id, app_id, uri_string, UNIX_TIMESTAMP(uri_created)
FROM msz_apps_uris
WHERE app_id = ?
SQL);
$stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
$stmt->execute();
return $stmt->getResultIterator(AppUriInfo::fromResult(...));
}
public function getAppUriInfo(string $uriId): AppUriInfo {
$stmt = $this->cache->get(<<<SQL
SELECT uri_id, app_id, uri_string, UNIX_TIMESTAMP(uri_created)
FROM msz_apps_uris
WHERE uri_id = ?
SQL);
$stmt->nextParameter($uriId);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('URI not found.');
return AppUriInfo::fromResult($result);
}
public function getAppUriId(AppInfo|string $appInfo, string $uriString): ?string {
$stmt = $this->cache->get('SELECT uri_id FROM msz_apps_uris WHERE app_id = ? AND uri_string = ?');
$stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
$stmt->nextParameter($uriString);
$stmt->execute();
$result = $stmt->getResult();
return $result->next() ? $result->getStringOrNull(0) : null;
}
public function getAppScopes(AppInfo|string $appInfo): AppScopesInfo {
$allowed = [];
$denied = [];
$stmt = $this->cache->get(<<<SQL
SELECT (
SELECT scope_string
FROM msz_scopes AS _s
WHERE _s.scope_id = _as.scope_id
), scope_allowed
FROM msz_apps_scopes AS _as
WHERE app_id = ?
SQL);
$stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
$stmt->execute();
$result = $stmt->getResult();
while($result->next()) {
$scopeStr = $result->getString(0);
if($result->getBoolean(1))
$allowed[] = $scopeStr;
else
$denied[] = $scopeStr;
}
return new AppScopesInfo($allowed, $denied);
}
public function setAppScopeAllow(AppInfo|string $appInfo, ScopeInfo $scopeInfo, ?bool $allowed): void {
if($allowed !== $scopeInfo->restricted) {
$stmt = $this->cache->get('DELETE FROM msz_apps_scopes WHERE app_id = ? AND scope_id = ?');
} else {
$stmt = $this->cache->get('REPLACE INTO msz_apps_scopes (app_id, scope_id, scope_allowed) VALUES (?, ?, ?)');
$stmt->addParameter(3, $allowed ? 1 : 0);
}
$stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
$stmt->nextParameter($scopeInfo->id);
$stmt->execute();
}
public function isAppScopeAllowed(AppInfo|string $appInfo, ScopeInfo|string $scopeInfo): bool {
$stmt = $this->cache->get(<<<SQL
SELECT ? AS _scope_id, COALESCE(
(SELECT scope_allowed FROM msz_apps_scopes WHERE app_id = ? AND scope_id = _scope_id),
(SELECT NOT scope_restricted FROM msz_scopes WHERE scope_id = _scope_id)
)
SQL);
$stmt->nextParameter($scopeInfo instanceof ScopeInfo ? $scopeInfo->id : $scopeInfo);
$stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('failed to check scope ACL');
return $result->getBoolean(1);
}
}

39
src/Apps/ScopeInfo.php Normal file
View file

@ -0,0 +1,39 @@
<?php
namespace Misuzu\Apps;
use Carbon\CarbonImmutable;
use Index\Db\DbResult;
class ScopeInfo {
public function __construct(
public private(set) string $id,
public private(set) string $string,
public private(set) bool $restricted,
public private(set) string $summary,
public private(set) int $createdTime,
public private(set) ?int $deprecatedTime
) {}
public static function fromResult(DbResult $result): ScopeInfo {
return new ScopeInfo(
id: $result->getString(0),
string: $result->getString(1),
restricted: $result->getBoolean(2),
summary: $result->getString(3),
createdTime: $result->getInteger(4),
deprecatedTime: $result->getIntegerOrNull(5)
);
}
public CarbonImmutable $createdAt {
get => CarbonImmutable::createFromTimestampUTC($this->createdTime);
}
public bool $deprecated {
get => $this->deprecatedTime !== null;
}
public ?CarbonImmutable $deprecatedAt {
get => $this->deprecatedTime === null ? null : CarbonImmutable::createFromTimestampUTC($this->deprecatedTime);
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace Misuzu\Apps;
enum ScopeInfoGetField {
case Id;
case String;
}

57
src/Apps/ScopesData.php Normal file
View file

@ -0,0 +1,57 @@
<?php
namespace Misuzu\Apps;
use InvalidArgumentException;
use RuntimeException;
use Index\Db\{DbConnection,DbStatementCache};
class ScopesData {
private DbStatementCache $cache;
public function __construct(DbConnection $dbConn) {
$this->cache = new DbStatementCache($dbConn);
}
public function getScopeInfo(string $value, ScopeInfoGetField $field): ScopeInfo {
$stmt = $this->cache->get(sprintf(
<<<SQL
SELECT scope_id, scope_string, scope_restricted, scope_summary,
UNIX_TIMESTAMP(scope_created), UNIX_TIMESTAMP(scope_deprecated)
FROM msz_scopes
WHERE %s = ?
SQL,
match($field) {
ScopeInfoGetField::Id => 'scope_id',
ScopeInfoGetField::String => 'scope_string',
}
));
$stmt->nextParameter($value);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('Scope not found.');
return ScopeInfo::fromResult($result);
}
public function createScope(string $string, bool $restricted, string $summary = ''): ScopeInfo {
if(trim($string) === '')
throw new InvalidArgumentException('$string may not be empty');
if(preg_match('#[^A-Za-z0-9:-]#', $string))
throw new InvalidArgumentException('$string contains invalid characters');
$stmt = $this->cache->get(<<<SQL
INSERT INTO msz_scopes (
scope_string, scope_restricted, scope_summary
) VALUES (?, ?, ?)
SQL);
$stmt->nextParameter($string);
$stmt->nextParameter($restricted ? 1 : 0);
$stmt->nextParameter(trim($summary));
$stmt->execute();
return $this->getScopeInfo((string)$stmt->lastInsertId, ScopeInfoGetField::Id);
}
}

View file

@ -89,7 +89,7 @@ class AuditLogData {
$stmt->execute();
return $stmt->getResult()->getIterator(AuditLogInfo::fromResult(...));
return $stmt->getResultIterator(AuditLogInfo::fromResult(...));
}
/** @param mixed[] $params */

View file

@ -39,7 +39,7 @@ class AuthInfo {
$this->setInfo(AuthTokenInfo::empty());
}
public bool $isLoggedIn {
public bool $loggedIn {
get => $this->userInfo !== null;
}

View file

@ -113,7 +113,7 @@ class LoginAttemptsData {
$pagination->addToStatement($stmt);
$stmt->execute();
return $stmt->getResult()->getIterator(LoginAttemptInfo::fromResult(...));
return $stmt->getResultIterator(LoginAttemptInfo::fromResult(...));
}
public function recordAttempt(

View file

@ -79,7 +79,7 @@ class SessionsData {
$pagination->addToStatement($stmt);
$stmt->execute();
return $stmt->getResult()->getIterator(SessionInfo::fromResult(...));
return $stmt->getResultIterator(SessionInfo::fromResult(...));
}
public function getSession(

View file

@ -166,7 +166,7 @@ class ChangelogData {
$stmt->execute();
return $stmt->getResult()->getIterator(ChangeInfo::fromResult(...));
return $stmt->getResultIterator(ChangeInfo::fromResult(...));
}
public function getChange(string $changeId): ChangeInfo {

View file

@ -66,7 +66,7 @@ class CommentsData {
$stmt->execute();
return $stmt->getResult()->getIterator(CommentsCategoryInfo::fromResult(...));
return $stmt->getResultIterator(CommentsCategoryInfo::fromResult(...));
}
public function getCategory(
@ -317,7 +317,7 @@ class CommentsData {
$stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
$stmt->execute();
return $stmt->getResult()->getIterator(fn($result) => CommentsPostInfo::fromResult($result, $includeRepliesCount, $includeVotesCount));
return $stmt->getResultIterator(fn($result) => CommentsPostInfo::fromResult($result, $includeRepliesCount, $includeVotesCount));
}
public function getPost(

View file

@ -20,7 +20,7 @@ class CommentsEx {
if(is_string($category))
$category = $this->comments->ensureCategory($category);
$hasUser = $this->authInfo->isLoggedIn;
$hasUser = $this->authInfo->loggedIn;
$info->user = $hasUser ? $this->authInfo->userInfo : null;
$info->colour = $this->usersCtx->getUserColour($info->user);
$info->perms = $this->authInfo->getPerms('global')->checkMany([

View file

@ -43,7 +43,7 @@ class CountersData {
$pagination->addToStatement($stmt);
$stmt->execute();
return $stmt->getResult()->getIterator(CounterInfo::fromResult(...));
return $stmt->getResultIterator(CounterInfo::fromResult(...));
}
/**

View file

@ -66,7 +66,7 @@ class EmotesData {
$stmt->nextParameter($minRank);
$stmt->execute();
return $stmt->getResult()->getIterator(EmoteInfo::fromResult(...));
return $stmt->getResultIterator(EmoteInfo::fromResult(...));
}
private static function checkEmoteUrlInternal(string $url): string {
@ -160,7 +160,7 @@ class EmotesData {
$stmt->nextParameter($infoOrId);
$stmt->execute();
return $stmt->getResult()->getIterator(EmoteStringInfo::fromResult(...));
return $stmt->getResultIterator(EmoteStringInfo::fromResult(...));
}
private static function checkEmoteStringInternal(string $string): string {

View file

@ -155,7 +155,7 @@ class ForumCategoriesData {
$pagination->addToStatement($stmt);
$stmt->execute();
$cats = $stmt->getResult()->getIterator(ForumCategoryInfo::fromResult(...));
$cats = $stmt->getResultIterator(ForumCategoryInfo::fromResult(...));
if($asTree)
$cats = self::convertCategoryListToTree($cats);
@ -274,7 +274,7 @@ class ForumCategoriesData {
$stmt->nextParameter($categoryInfo);
$stmt->execute();
return $stmt->getResult()->getIterator(ForumCategoryInfo::fromResult(...));
return $stmt->getResultIterator(ForumCategoryInfo::fromResult(...));
}
/** @return \Iterator<int, ForumCategoryInfo>|object{info: ForumCategoryInfo, colour: Colour, children: mixed[], childIds: string[]}[] */
@ -310,7 +310,7 @@ class ForumCategoriesData {
$stmt->nextParameter($parentInfo);
$stmt->execute();
$cats = $stmt->getResult()->getIterator(ForumCategoryInfo::fromResult(...));
$cats = $stmt->getResultIterator(ForumCategoryInfo::fromResult(...));
if($asTree)
$cats = self::convertCategoryListToTree($cats, $parentInfo);

View file

@ -166,7 +166,7 @@ class ForumCategoriesRoutes implements RouteHandler, UrlSource {
return Template::renderRaw('forum.index', [
'forum_categories' => $cats,
'forum_empty' => empty($cats),
'forum_show_mark_as_read' => $this->authInfo->isLoggedIn,
'forum_show_mark_as_read' => $this->authInfo->loggedIn,
]);
}
@ -321,7 +321,7 @@ class ForumCategoriesRoutes implements RouteHandler, UrlSource {
'forum_children' => $children,
'forum_topics' => $topics,
'forum_pagination' => $pagination,
'forum_show_mark_as_read' => $this->authInfo->isLoggedIn,
'forum_show_mark_as_read' => $this->authInfo->loggedIn,
'forum_perms' => $perms->checkMany([
'can_create_topic' => Perm::F_TOPIC_CREATE,
]),
@ -331,7 +331,7 @@ class ForumCategoriesRoutes implements RouteHandler, UrlSource {
#[HttpPost('/forum/mark-as-read')]
#[UrlFormat('forum-mark-as-read', '/forum/mark-as-read', ['cat' => '<category>', 'rec' => '<recursive>'])]
public function postMarkAsRead(HttpResponseBuilder $response, HttpRequest $request): mixed {
if(!$this->authInfo->isLoggedIn)
if(!$this->authInfo->loggedIn)
return 401;
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))

View file

@ -185,7 +185,7 @@ class ForumPostsData {
$pagination->addToStatement($stmt);
$stmt->execute();
return $stmt->getResult()->getIterator(ForumPostInfo::fromResult(...));
return $stmt->getResultIterator(ForumPostInfo::fromResult(...));
}
/** @param ForumCategoryInfo|string|null|array<ForumCategoryInfo|string|int> $categoryInfos */

View file

@ -57,7 +57,7 @@ class ForumPostsRoutes implements RouteHandler, UrlSource {
#[HttpDelete('/forum/posts/([0-9]+)')]
#[UrlFormat('forum-post-delete', '/forum/posts/<post>')]
public function deletePost(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed {
if(!$this->authInfo->isLoggedIn)
if(!$this->authInfo->loggedIn)
return 401;
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
@ -192,7 +192,7 @@ class ForumPostsRoutes implements RouteHandler, UrlSource {
#[HttpPost('/forum/posts/([0-9]+)/nuke')]
#[UrlFormat('forum-post-nuke', '/forum/posts/<post>/nuke')]
public function postPostNuke(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed {
if(!$this->authInfo->isLoggedIn)
if(!$this->authInfo->loggedIn)
return 401;
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
@ -272,7 +272,7 @@ class ForumPostsRoutes implements RouteHandler, UrlSource {
#[HttpPost('/forum/posts/([0-9]+)/restore')]
#[UrlFormat('forum-post-restore', '/forum/posts/<post>/restore')]
public function postPostRestore(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed {
if(!$this->authInfo->isLoggedIn)
if(!$this->authInfo->loggedIn)
return 401;
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))

View file

@ -58,7 +58,7 @@ class ForumTopicRedirectsData {
$pagination->addToStatement($stmt);
$stmt->execute();
return $stmt->getResult()->getIterator(ForumTopicRedirectInfo::fromResult(...));
return $stmt->getResultIterator(ForumTopicRedirectInfo::fromResult(...));
}
public function hasTopicRedirect(ForumTopicInfo|string $topicInfo): bool {

View file

@ -192,7 +192,7 @@ class ForumTopicsData {
$pagination->addToStatement($stmt);
$stmt->execute();
return $stmt->getResult()->getIterator(ForumTopicInfo::fromResult(...));
return $stmt->getResultIterator(ForumTopicInfo::fromResult(...));
}
public function getTopic(

View file

@ -149,7 +149,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
#[HttpDelete('/forum/topics/([0-9]+)')]
#[UrlFormat('forum-topic-delete', '/forum/topics/<topic>')]
public function deleteTopic(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
if(!$this->authInfo->isLoggedIn)
if(!$this->authInfo->loggedIn)
return 401;
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
@ -269,7 +269,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
#[HttpPost('/forum/topics/([0-9]+)/restore')]
#[UrlFormat('forum-topic-restore', '/forum/topics/<topic>/restore')]
public function postTopicRestore(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
if(!$this->authInfo->isLoggedIn)
if(!$this->authInfo->loggedIn)
return 401;
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
@ -349,7 +349,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
#[HttpPost('/forum/topics/([0-9]+)/nuke')]
#[UrlFormat('forum-topic-nuke', '/forum/topics/<topic>/nuke')]
public function postTopicNuke(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
if(!$this->authInfo->isLoggedIn)
if(!$this->authInfo->loggedIn)
return 401;
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
@ -429,7 +429,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
#[HttpPost('/forum/topics/([0-9]+)/bump')]
#[UrlFormat('forum-topic-bump', '/forum/topics/<topic>/bump')]
public function postTopicBump(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
if(!$this->authInfo->isLoggedIn)
if(!$this->authInfo->loggedIn)
return 401;
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
@ -509,7 +509,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
#[HttpPost('/forum/topics/([0-9]+)/lock')]
#[UrlFormat('forum-topic-lock', '/forum/topics/<topic>/lock')]
public function postTopicLock(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
if(!$this->authInfo->isLoggedIn)
if(!$this->authInfo->loggedIn)
return 401;
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
@ -599,7 +599,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
#[HttpPost('/forum/topics/([0-9]+)/unlock')]
#[UrlFormat('forum-topic-unlock', '/forum/topics/<topic>/unlock')]
public function postTopicUnlock(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
if(!$this->authInfo->isLoggedIn)
if(!$this->authInfo->loggedIn)
return 401;
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))

View file

@ -199,7 +199,7 @@ class HomeRoutes implements RouteHandler, UrlSource {
#[HttpGet('/')]
#[UrlFormat('index', '/')]
public function getIndex(HttpResponseBuilder $response, HttpRequest $request): string {
return $this->authInfo->isLoggedIn
return $this->authInfo->loggedIn
? $this->getHome()
: $this->getLanding($response, $request);
}

View file

@ -151,7 +151,7 @@ class MessagesData {
$stmt->execute();
return $stmt->getResult()->getIterator(MessageInfo::fromResult(...));
return $stmt->getResultIterator(MessageInfo::fromResult(...));
}
public function getMessageInfo(

View file

@ -41,7 +41,7 @@ class MessagesRoutes implements RouteHandler, UrlSource {
#[HttpMiddleware('/messages')]
public function checkAccess(HttpResponseBuilder $response, HttpRequest $request) {
// should probably be a permission or something too
if(!$this->authInfo->isLoggedIn)
if(!$this->authInfo->loggedIn)
return 401;
// do not allow access to PMs when impersonating in production mode

View file

@ -9,6 +9,7 @@ use Index\Http\HttpRequest;
use Index\Templating\TplEnvironment;
use Index\Urls\UrlRegistry;
use Misuzu\Template;
use Misuzu\Apps\AppsContext;
use Misuzu\Auth\{AuthContext,AuthInfo};
use Misuzu\AuditLog\AuditLogData;
use Misuzu\Changelog\ChangelogData;
@ -18,6 +19,7 @@ use Misuzu\Emoticons\EmotesData;
use Misuzu\Forum\ForumContext;
use Misuzu\Messages\MessagesContext;
use Misuzu\News\NewsData;
use Misuzu\OAuth2\OAuth2Context;
use Misuzu\Perms\PermissionsData;
use Misuzu\Profile\ProfileFieldsData;
use Misuzu\Redirects\RedirectsContext;
@ -45,9 +47,11 @@ class MisuzuContext {
public private(set) NewsData $news;
public private(set) CommentsData $comments;
public private(set) AppsContext $appsCtx;
public private(set) AuthContext $authCtx;
public private(set) ForumContext $forumCtx;
public private(set) MessagesContext $messagesCtx;
public private(set) OAuth2Context $oauth2Ctx;
public private(set) UsersContext $usersCtx;
public private(set) RedirectsContext $redirectsCtx;
@ -75,9 +79,11 @@ class MisuzuContext {
$this->deps->register($this->authInfo = $this->deps->constructLazy(AuthInfo::class));
$this->deps->register($this->siteInfo = $this->deps->constructLazy(SiteInfo::class, config: $config->scopeTo('site')));
$this->deps->register($this->appsCtx = $this->deps->constructLazy(AppsContext::class));
$this->deps->register($this->authCtx = $this->deps->constructLazy(AuthContext::class, config: $config->scopeTo('auth')));
$this->deps->register($this->forumCtx = $this->deps->constructLazy(ForumContext::class));
$this->deps->register($this->messagesCtx = $this->deps->constructLazy(MessagesContext::class));
$this->deps->register($this->oauth2Ctx = $this->deps->constructLazy(OAuth2Context::class, config: $config->scopeTo('oauth2')));
$this->deps->register($this->usersCtx = $this->deps->constructLazy(UsersContext::class));
$this->deps->register($this->redirectsCtx = $this->deps->constructLazy(RedirectsContext::class, config: $config->scopeTo('redirects')));
@ -105,7 +111,7 @@ class MisuzuContext {
/** @param mixed[] $params */
public function createAuditLog(string $action, array $params = [], UserInfo|string|null $userInfo = null): void {
if($userInfo === null && $this->authInfo->isLoggedIn)
if($userInfo === null && $this->authInfo->loggedIn)
$userInfo = $this->authInfo->userInfo;
$this->auditLog->createLog(
@ -119,7 +125,7 @@ class MisuzuContext {
private ?bool $hasManageAccess = null;
public function hasManageAccess(): bool {
$this->hasManageAccess ??= $this->authInfo->isLoggedIn
$this->hasManageAccess ??= $this->authInfo->loggedIn
&& !$this->usersCtx->hasActiveBan($this->authInfo->userInfo)
&& $this->authInfo->getPerms('global')->check(Perm::G_IS_JANITOR);
return $this->hasManageAccess;
@ -167,6 +173,11 @@ class MisuzuContext {
$routingCtx = new BackedRoutingContext;
$this->deps->register($this->urls = $routingCtx->urls);
$rpcServer = new HttpRpcServer;
$routingCtx->register($rpcServer->createRouteHandler(
new HmacVerificationProvider(fn() => $this->config->getString('aleister.secret'))
));
if(in_array('main', $purposes)) {
$scopedInfo = $hostInfo->scopeTo('main');
$scopedCtx = $routingCtx->scopeTo(
@ -197,10 +208,6 @@ class MisuzuContext {
));
$scopedCtx->register($this->deps->constructLazy(LegacyRoutes::class));
$rpcServer = new HttpRpcServer;
$scopedCtx->register($rpcServer->createRouteHandler(
new HmacVerificationProvider(fn() => $this->config->getString('aleister.secret'))
));
$rpcServer->register($this->deps->constructLazy(
Auth\AuthRpcHandler::class,
impersonateConfig: $this->config->scopeTo('impersonate')
@ -220,6 +227,18 @@ class MisuzuContext {
));
}
if(in_array('id', $purposes)) {
$scopedInfo = $hostInfo->scopeTo('id');
$scopedCtx = $routingCtx->scopeTo(
$scopedInfo->getString('name'),
$scopedInfo->getString('path')
);
$scopedCtx->register($this->deps->constructLazy(OAuth2\OAuth2ApiRoutes::class));
$scopedCtx->register($this->deps->constructLazy(OAuth2\OAuth2WebRoutes::class));
$rpcServer->register($this->deps->constructLazy(OAuth2\OAuth2RpcHandler::class));
}
if(in_array('redirect', $purposes)) {
$scopedInfo = $hostInfo->scopeTo('redirect');
$scopedCtx = $routingCtx->scopeTo(

View file

@ -53,7 +53,7 @@ class NewsData {
$stmt->execute();
return $stmt->getResult()->getIterator(NewsCategoryInfo::fromResult(...));
return $stmt->getResultIterator(NewsCategoryInfo::fromResult(...));
}
public function getCategory(
@ -255,7 +255,7 @@ class NewsData {
$stmt->execute();
return $stmt->getResult()->getIterator(NewsPostInfo::fromResult(...));
return $stmt->getResultIterator(NewsPostInfo::fromResult(...));
}
public function getPost(string $postId): NewsPostInfo {

View file

@ -0,0 +1,52 @@
<?php
namespace Misuzu\OAuth2;
use Carbon\CarbonImmutable;
use Index\Db\DbResult;
class OAuth2AccessInfo {
public const int DEFAULT_LIFETIME = 3600;
public function __construct(
public private(set) string $id,
public private(set) string $appId,
public private(set) ?string $userId,
public private(set) string $token,
public private(set) string $scope,
public private(set) int $createdTime,
public private(set) int $expiresTime
) {}
public static function fromResult(DbResult $result): OAuth2AccessInfo {
return new OAuth2AccessInfo(
id: $result->getString(0),
appId: $result->getString(1),
userId: $result->getStringOrNull(2),
token: $result->getString(3),
scope: $result->getString(4),
createdTime: $result->getInteger(5),
expiresTime: $result->getInteger(6),
);
}
/** @var string[] */
public array $scopes {
get => explode(' ', $this->scope);
}
public CarbonImmutable $createdAt {
get => CarbonImmutable::createFromTimestampUTC($this->createdTime);
}
public bool $expired {
get => time() > $this->expiresTime;
}
public CarbonImmutable $expiresAt {
get => CarbonImmutable::createFromTimestampUTC($this->expiresTime);
}
public int $remainingLifetime {
get => max(0, $this->expiresTime - time());
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace Misuzu\OAuth2;
enum OAuth2AccessInfoGetField {
case Id;
case Token;
}

View file

@ -0,0 +1,215 @@
<?php
namespace Misuzu\OAuth2;
use RuntimeException;
use Index\Http\{FormHttpContent,HttpResponseBuilder,HttpRequest};
use Index\Http\Routing\{HttpGet,HttpOptions,HttpPost,RouteHandler,RouteHandlerCommon};
final class OAuth2ApiRoutes implements RouteHandler {
use RouteHandlerCommon;
public function __construct(
private OAuth2Context $oauth2Ctx
) {}
/**
* @param array<string, scalar> $result
* @return array<string, scalar>
*/
private static function filter(
HttpResponseBuilder $response,
HttpRequest $request,
array $result,
bool $authzHeader = false
): array {
if(array_key_exists('error', $result)) {
if($authzHeader) {
$wwwAuth = sprintf('Basic realm="%s"', (string)$request->getHeaderLine('Host'));
foreach($result as $name => $value)
$wwwAuth .= sprintf(', %s="%s"', $name, rawurlencode($value));
$response->statusCode = 401;
$response->setHeader('WWW-Authenticate', $wwwAuth);
} else
$response->statusCode = 400;
}
return $result;
}
/**
* @return array{
* device_code: string,
* user_code: string,
* verification_uri: string,
* verification_uri_complete: string,
* expires_in?: int,
* interval?: int
* }|array{ error: string, error_description: string }
*/
#[HttpPost('/oauth2/request-authorise')]
public function postRequestAuthorise(HttpResponseBuilder $response, HttpRequest $request): array {
$response->setHeader('Cache-Control', 'no-store');
if(!($request->content instanceof FormHttpContent))
return self::filter($response, $request, [
'error' => 'invalid_request',
'error_description' => 'Your request must use content type application/x-www-form-urlencoded.',
]);
$authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization'));
if(strcasecmp($authzHeader[0], 'Basic') === 0) {
$authzHeader = explode(':', base64_decode($authzHeader[1] ?? ''));
$clientId = $authzHeader[0];
$clientSecret = $authzHeader[1] ?? '';
} elseif($authzHeader[0] !== '') {
return self::filter($response, $request, [
'error' => 'invalid_client',
'error_description' => 'You must use the Basic method for Authorization parameters.',
], authzHeader: true);
} else {
$clientId = (string)$request->content->getParam('client_id');
$clientSecret = '';
}
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false);
} catch(RuntimeException $ex) {
return self::filter($response, $request, [
'error' => 'invalid_client',
'error_description' => 'No application has been registered with this client ID.',
], authzHeader: $authzHeader[0] !== '');
}
if($clientSecret !== '') {
// TODO: rate limiting
if(!$appInfo->verifyClientSecret($clientSecret))
return self::filter($response, $request, [
'error' => 'invalid_client',
'error_description' => 'Provided client secret is not correct for this application.',
], authzHeader: true);
}
return self::filter($response, $request, $this->oauth2Ctx->createDeviceAuthorisationRequest(
$appInfo,
$request->content->hasParam('scope') ? (string)$request->content->getParam('scope') : null
));
}
/**
* @return array{
* access_token: string,
* token_type: 'Bearer',
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* }|array{ error: string, error_description: string }
*/
#[HttpOptions('/oauth2/token')]
#[HttpPost('/oauth2/token')]
public function postToken(HttpResponseBuilder $response, HttpRequest $request): array|int {
$response->setHeader('Cache-Control', 'no-store');
$originHeaders = ['Origin', 'X-Origin', 'Referer'];
$origins = [];
foreach($originHeaders as $originHeader) {
$originHeader = $request->getHeaderFirstLine($originHeader);
if($originHeader !== '' && !in_array($originHeader, $origins))
$origins[] = $originHeader;
}
if(!empty($origins)) {
// TODO: check if none of the provided origins is on a blocklist or something
// different origins being specified for each header should probably also be considered suspect...
$response->setHeader('Access-Control-Allow-Origin', $origins[0]);
$response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, POST');
$response->setHeader('Access-Control-Allow-Headers', 'Authorization');
$response->setHeader('Access-Control-Expose-Headers', 'Vary');
foreach($originHeaders as $originHeader)
$response->setHeader('Vary', $originHeader);
}
if($request->method === 'OPTIONS')
return 204;
if(!($request->content instanceof FormHttpContent))
return self::filter($response, $request, [
'error' => 'invalid_request',
'error_description' => 'Your request must use content type application/x-www-form-urlencoded.',
]);
// authz header should be the preferred method
$authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization'));
if(strcasecmp($authzHeader[0], 'Basic') === 0) {
$authzHeader = explode(':', base64_decode($authzHeader[1] ?? ''));
$clientId = $authzHeader[0];
$clientSecret = $authzHeader[1] ?? '';
} elseif($authzHeader[0] !== '') {
return self::filter($response, $request, [
'error' => 'invalid_client',
'error_description' => 'You must either use the Basic method for Authorization or use the client_id and client_secret parameters.',
], authzHeader: true);
} else {
$clientId = (string)$request->content->getParam('client_id');
$clientSecret = (string)$request->content->getParam('client_secret');
}
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false);
} catch(RuntimeException $ex) {
return self::filter($response, $request, [
'error' => 'invalid_client',
'error_description' => 'No application has been registered with this client ID.',
], authzHeader: $authzHeader[0] !== '');
}
$isAuthed = false;
if($clientSecret !== '') {
// TODO: rate limiting
$isAuthed = $appInfo->verifyClientSecret($clientSecret);
if(!$isAuthed)
return self::filter($response, $request, [
'error' => 'invalid_client',
'error_description' => 'Provided client secret is not correct for this application.',
], authzHeader: $authzHeader[0] !== '');
}
$type = (string)$request->content->getParam('grant_type');
if($type === 'authorization_code')
return self::filter($response, $request, $this->oauth2Ctx->redeemAuthorisationCode(
$appInfo,
$isAuthed,
(string)$request->content->getParam('code'),
(string)$request->content->getParam('code_verifier')
));
if($type === 'refresh_token')
return self::filter($response, $request, $this->oauth2Ctx->redeemRefreshToken(
$appInfo,
$isAuthed,
(string)$request->content->getParam('refresh_token'),
$request->content->hasParam('scope') ? (string)$request->content->getParam('scope') : null
));
if($type === 'client_credentials')
return self::filter($response, $request, $this->oauth2Ctx->redeemClientCredentials(
$appInfo,
$isAuthed,
$request->content->hasParam('scope') ? (string)$request->content->getParam('scope') : null
));
if($type === 'urn:ietf:params:oauth:grant-type:device_code' || $type === 'device_code')
return self::filter($response, $request, $this->oauth2Ctx->redeemDeviceCode(
$appInfo,
$isAuthed,
(string)$request->content->getParam('device_code')
));
return self::filter($response, $request, [
'error' => 'unsupported_grant_type',
'error_description' => 'Requested grant type is not supported by this server.',
]);
}
}

View file

@ -0,0 +1,113 @@
<?php
namespace Misuzu\OAuth2;
use InvalidArgumentException;
use RuntimeException;
use Index\XString;
use Index\Db\{DbConnection,DbStatementCache};
use Misuzu\Apps\{AppInfo,AppUriInfo};
use Misuzu\Users\UserInfo;
class OAuth2AuthorisationData {
private DbStatementCache $cache;
public function __construct(
private DbConnection $dbConn
) {
$this->cache = new DbStatementCache($dbConn);
}
public function getAuthorisationInfo(
?string $authsId = null,
AppInfo|string|null $appInfo = null,
?string $code = null
): OAuth2AuthorisationInfo {
$selectors = [];
$values = [];
if($authsId !== null) {
$selectors[] = 'auth_id = ?';
$values[] = $authsId;
}
if($appInfo !== null) {
$selectors[] = 'app_id = ?';
$values[] = $appInfo instanceof AppInfo ? $appInfo->id : $appInfo;
}
if($code !== null) {
$selectors[] = 'auth_code = ?';
$values[] = $code;
}
if(empty($selectors))
throw new RuntimeException('Insufficient data to do authorisation request lookup.');
$stmt = $this->cache->get(sprintf(
<<<SQL
SELECT auth_id, app_id, user_id, uri_id, auth_challenge_code,
auth_challenge_method, auth_scope, auth_code,
UNIX_TIMESTAMP(auth_created), UNIX_TIMESTAMP(auth_expires)
FROM msz_oauth2_authorise WHERE %s
SQL
, implode(' AND ', $selectors)));
foreach($values as $value)
$stmt->nextParameter($value);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('Authorise request not found.');
return OAuth2AuthorisationInfo::fromResult($result);
}
public function createAuthorisation(
AppInfo|string $appInfo,
UserInfo|string $userInfo,
AppUriInfo|string $appUriInfo,
string $challengeCode,
string $challengeMethod,
string $scope,
?string $code = null
): OAuth2AuthorisationInfo {
$code ??= XString::random(60);
$stmt = $this->cache->get(<<<SQL
INSERT INTO msz_oauth2_authorise (
app_id, user_id, uri_id, auth_challenge_code, auth_challenge_method, auth_scope, auth_code
) VALUES (?, ?, ?, ?, ?, ?, ?)
SQL);
$stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
$stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
$stmt->nextParameter($appUriInfo instanceof AppUriInfo ? $appUriInfo->id : $appUriInfo);
$stmt->nextParameter($challengeCode);
$stmt->nextParameter($challengeMethod);
$stmt->nextParameter($scope);
$stmt->nextParameter($code);
$stmt->execute();
return $this->getAuthorisationInfo(authsId: (string)$stmt->lastInsertId);
}
public function deleteAuthorisation(
OAuth2AuthorisationInfo|string|null $authsInfo = null
): void {
$selectors = [];
$values = [];
if($authsInfo !== null) {
$selectors[] = 'auth_id = ?';
$values[] = $authsInfo instanceof OAuth2AuthorisationInfo ? $authsInfo->id : $authsInfo;
}
$query = 'DELETE FROM msz_oauth2_authorise';
if(!empty($selectors))
$query .= sprintf(' WHERE %s', implode(' AND ', $selectors));
$stmt = $this->cache->get($query);
foreach($values as $value)
$stmt->nextParameter($value);
$stmt->execute();
}
public function pruneExpiredAuthorisations(): int {
return (int)$this->dbConn->execute('DELETE FROM msz_oauth2_authorise WHERE auth_expires <= NOW() - INTERVAL 1 HOUR');
}
}

View file

@ -0,0 +1,70 @@
<?php
namespace Misuzu\OAuth2;
use Carbon\CarbonImmutable;
use Index\UriBase64;
use Index\Db\DbResult;
class OAuth2AuthorisationInfo {
public function __construct(
public private(set) string $id,
public private(set) string $appId,
public private(set) string $userId,
public private(set) string $uriId,
public private(set) string $challengeCode,
public private(set) string $challengeMethod,
public private(set) string $scope,
public private(set) string $code,
public private(set) int $createdTime,
public private(set) int $expiresTime
) {}
public static function fromResult(DbResult $result): OAuth2AuthorisationInfo {
return new OAuth2AuthorisationInfo(
id: $result->getString(0),
appId: $result->getString(1),
userId: $result->getString(2),
uriId: $result->getString(3),
challengeCode: $result->getString(4),
challengeMethod: $result->getString(5),
scope: $result->getString(6),
code: $result->getString(7),
createdTime: $result->getInteger(8),
expiresTime: $result->getInteger(9),
);
}
public function verifyCodeChallenge(string $codeVerifier): bool {
if($this->challengeMethod === 'plain')
return hash_equals($this->challengeCode, $codeVerifier);
if($this->challengeMethod === 'S256') {
$knownHash = UriBase64::decode($this->challengeCode);
$userHash = hash('sha256', $codeVerifier, true);
return hash_equals($knownHash, $userHash);
}
return false;
}
/** @var string[] */
public array $scopes {
get => explode(' ', $this->scope);
}
public CarbonImmutable $createdAt {
get => CarbonImmutable::createFromTimestampUTC($this->createdTime);
}
public bool $expired {
get => time() > $this->expiresTime;
}
public CarbonImmutable $expiresAt {
get => CarbonImmutable::createFromTimestampUTC($this->expiresTime);
}
public int $remainingLifetime {
get => max(0, $this->expiresTime - time());
}
}

View file

@ -0,0 +1,398 @@
<?php
namespace Misuzu\OAuth2;
use RuntimeException;
use Index\Config\Config;
use Index\Db\DbConnection;
use Misuzu\Apps\{AppsContext,AppInfo};
use Misuzu\Users\UserInfo;
class OAuth2Context {
public private(set) OAuth2AuthorisationData $authorisations;
public private(set) OAuth2TokensData $tokens;
public private(set) OAuth2DevicesData $devices;
public function __construct(
private Config $config,
DbConnection $dbConn,
public private(set) AppsContext $appsCtx
) {
$this->authorisations = new OAuth2AuthorisationData($dbConn);
$this->tokens = new OAuth2TokensData($dbConn);
$this->devices = new OAuth2DevicesData($dbConn);
}
/**
* @param ?callable(string $line, scalar ...$args): void $logAction
*/
public function pruneExpired($logAction = null): void {
$logAction ??= function() {};
$logAction('Pruning expired refresh tokens...');
$pruned = $this->tokens->pruneExpiredRefresh();
$logAction(' Removed %d!', $pruned);
$logAction('Pruning expired access tokens...');
$pruned = $this->tokens->pruneExpiredAccess();
$logAction(' Removed %d!', $pruned);
$logAction('Pruning expired device authorisation requests...');
$pruned = $this->devices->pruneExpiredDevices();
$logAction(' Removed %d!', $pruned);
$logAction('Pruning expired authorisation codes...');
$pruned = $this->authorisations->pruneExpiredAuthorisations();
$logAction(' Removed %d!', $pruned);
}
public function createAccess(
AppInfo $appInfo,
string $scope = '',
UserInfo|string|null $userInfo = null
): OAuth2AccessInfo {
return $this->tokens->createAccess(
$appInfo,
userInfo: $userInfo,
scope: $scope,
lifetime: $appInfo->accessTokenLifetime,
);
}
public function createRefresh(
AppInfo $appInfo,
OAuth2AccessInfo $accessInfo
): OAuth2RefreshInfo {
return $this->tokens->createRefresh(
$appInfo,
$accessInfo,
userInfo: $accessInfo->userId,
scope: $accessInfo->scope,
lifetime: $appInfo->refreshTokenLifetime,
);
}
/** @return string|array{ error: string, error_description: string } */
public function checkAndBuildScopeString(
AppInfo $appInfo,
?string $scope = null,
bool $skipFail = false
): string|array {
if($scope === null)
return '';
$scopeInfos = $this->appsCtx->handleScopeString($appInfo, $scope, breakOnFail: !$skipFail);
$scope = [];
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo)) {
if($skipFail)
continue;
return [
'error' => 'invalid_scope',
'error_description' => sprintf('Requested scope "%s" is %s.', $scopeName, $scopeInfo),
];
}
$scope[] = $scopeInfo->string;
}
return implode(' ', $scope);
}
/**
* @return array{
* device_code: string,
* user_code: string,
* verification_uri: string,
* verification_uri_complete: string,
* expires_in?: int,
* interval?: int
* }|array{ error: string, error_description: string }
*/
public function createDeviceAuthorisationRequest(AppInfo $appInfo, ?string $scope = null) {
$scope = $this->checkAndBuildScopeString($appInfo, $scope);
if(is_array($scope))
return $scope;
$deviceInfo = $this->devices->createDevice($appInfo, $scope);
$userCode = $deviceInfo->getUserCodeDashed();
$result = [
'device_code' => $deviceInfo->code,
'user_code' => $userCode,
'verification_uri' => $this->config->getString('device.verification_uri'),
'verification_uri_complete' => sprintf($this->config->getString('device.verification_uri_complete'), $userCode),
];
$expiresIn = $deviceInfo->remainingLifetime;
if($expiresIn < OAuth2DeviceInfo::DEFAULT_LIFETIME)
$result['expires_in'] = $expiresIn;
$interval = $deviceInfo->interval;
if($interval > OAuth2DeviceInfo::DEFAULT_POLL_INTERVAL)
$result['interval'] = $interval;
return $result;
}
/**
* @return array{
* access_token: string,
* token_type: 'Bearer',
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* }
*/
public function packBearerTokenResult(
OAuth2AccessInfo $accessInfo,
?OAuth2RefreshInfo $refreshInfo = null,
?string $scope = null
): array {
$result = [
'access_token' => $accessInfo->token,
'token_type' => 'Bearer',
];
$expiresIn = $accessInfo->remainingLifetime;
if($expiresIn < OAuth2AccessInfo::DEFAULT_LIFETIME)
$result['expires_in'] = $expiresIn;
if($scope !== null)
$result['scope'] = $scope;
if($refreshInfo !== null)
$result['refresh_token'] = $refreshInfo->token;
return $result;
}
/**
* @return array{
* access_token: string,
* token_type: 'Bearer',
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* }|array{ error: string, error_description: string }
*/
public function redeemAuthorisationCode(AppInfo $appInfo, bool $isAuthed, string $code, string $codeVerifier): array {
try {
$authsInfo = $this->authorisations->getAuthorisationInfo(
appInfo: $appInfo,
code: $code,
);
} catch(RuntimeException $ex) {
return [
'error' => 'invalid_grant',
'error_description' => 'No authorisation request with this code exists.',
];
}
if($authsInfo->expired)
return [
'error' => 'invalid_grant',
'error_description' => 'Authorisation request has expired.',
];
if(!$authsInfo->verifyCodeChallenge($codeVerifier))
return [
'error' => 'invalid_request',
'error_description' => 'Code challenge verification failed.',
];
$this->authorisations->deleteAuthorisation($authsInfo);
$scope = $this->checkAndBuildScopeString($appInfo, $authsInfo->scope, true);
$accessInfo = $this->createAccess(
$appInfo,
$scope,
$authsInfo->userId,
);
if($authsInfo->scope === $scope)
$scope = null;
$refreshInfo = $appInfo->issueRefreshToken
? $this->createRefresh($appInfo, $accessInfo)
: null;
return $this->packBearerTokenResult($accessInfo, $refreshInfo, $scope);
}
/**
* @return array{
* access_token: string,
* token_type: 'Bearer',
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* }|array{ error: string, error_description: string }
*/
public function redeemRefreshToken(AppInfo $appInfo, bool $isAuthed, string $refreshToken, ?string $scope = null): array {
try {
$refreshInfo = $this->tokens->getRefreshInfo($refreshToken, OAuth2RefreshInfoGetField::Token);
} catch(RuntimeException $ex) {
return [
'error' => 'invalid_grant',
'error_description' => 'No such refresh token exists.',
];
}
if($refreshInfo->appId !== $appInfo->id)
return [
'error' => 'invalid_grant',
'error_description' => 'This refresh token is not associated with this application.',
];
if($refreshInfo->expired)
return [
'error' => 'invalid_grant',
'error_description' => 'This refresh token has expired.',
];
$this->tokens->deleteRefresh($refreshInfo);
if(!empty($refreshInfo->accessId))
$this->tokens->deleteAccess(accessInfo: $refreshInfo->accessId);
if($scope === null)
$scope = $refreshInfo->scope;
elseif(!empty(array_diff(explode(' ', $scope), $refreshInfo->scopes)))
return [
'error' => 'invalid_scope',
'error_description' => 'You cannot request a greater scope than during initial authorisation, please restart authorisation.',
];
$scope = $this->checkAndBuildScopeString($appInfo, $scope, true);
$accessInfo = $this->createAccess(
$appInfo,
$scope,
$refreshInfo->userId,
);
if($refreshInfo->scope === $scope)
$scope = null;
$refreshInfo = null;
if($appInfo->issueRefreshToken)
$refreshInfo = $this->createRefresh($appInfo, $accessInfo);
return $this->packBearerTokenResult($accessInfo, $refreshInfo, $scope);
}
/**
* @return array{
* access_token: string,
* token_type: 'Bearer',
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* }|array{ error: string, error_description: string }
*/
public function redeemClientCredentials(AppInfo $appInfo, bool $isAuthed, ?string $scope = null): array {
if(!$appInfo->confidential)
return [
'error' => 'unauthorized_client',
'error_description' => 'This application is not allowed to use this grant type.',
];
if(!$isAuthed)
return [
'error' => 'invalid_client',
'error_description' => 'Application must authenticate with client secret in order to use this grant type.',
];
$requestedScope = $scope;
$scope = $this->checkAndBuildScopeString($appInfo, $requestedScope, true);
$accessInfo = $this->createAccess($appInfo, $scope);
if($requestedScope !== null && $scope === '')
return [
'error' => 'invalid_scope',
'error_description' => 'Requested scope is not valid.',
];
if($requestedScope === null || $scope === $requestedScope)
$scope = null;
return $this->packBearerTokenResult($accessInfo, scope: $scope);
}
/**
* @return array{
* access_token: string,
* token_type: 'Bearer',
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* }|array{ error: string, error_description: string }
*/
public function redeemDeviceCode(AppInfo $appInfo, bool $isAuthed, string $deviceCode): array {
try {
$deviceInfo = $this->devices->getDeviceInfo(
appInfo: $appInfo,
code: $deviceCode,
);
} catch(RuntimeException) {
return [
'error' => 'invalid_grant',
'error_description' => 'No such device code exists.',
];
}
if($deviceInfo->expired)
return [
'error' => 'expired_token',
'error_description' => 'This device code has expired.',
];
if($deviceInfo->speedy) {
$this->devices->incrementDevicePollInterval($deviceInfo);
return [
'error' => 'slow_down',
'error_description' => 'You are polling too fast, please increase your interval by 5 seconds.',
];
}
if($deviceInfo->pending) {
$this->devices->bumpDevicePollTime($deviceInfo);
return [
'error' => 'authorization_pending',
'error_description' => 'User has not yet completed authorisation, check again in a bit.',
];
}
$this->devices->deleteDevice($deviceInfo);
if(!$deviceInfo->approved)
return [
'error' => 'access_denied',
'error_description' => 'User has rejected authorisation attempt.',
];
if(empty($deviceInfo->userId))
return [
'error' => 'invalid_request',
'error_description' => 'Device code was approved but has no associated user, please contact the system administrator because something is wrong.',
];
$scope = $this->checkAndBuildScopeString($appInfo, $deviceInfo->scope, true);
$accessInfo = $this->createAccess(
$appInfo,
$scope,
$deviceInfo->userId,
);
// 'scope' only has to be in the response if it differs from what was requested
if($deviceInfo->scope === $scope)
$scope = null;
$refreshInfo = $appInfo->issueRefreshToken
? $this->createRefresh($appInfo, $accessInfo)
: null;
return $this->packBearerTokenResult($accessInfo, $refreshInfo, $scope);
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace Misuzu\OAuth2;
enum OAuth2DeviceApproval: string {
case Pending = 'pending';
case Approved = 'approved';
case Denied = 'denied';
}

View file

@ -0,0 +1,87 @@
<?php
namespace Misuzu\OAuth2;
use Carbon\CarbonImmutable;
use Index\Db\DbResult;
class OAuth2DeviceInfo {
public const int DEFAULT_LIFETIME = 600;
public const int DEFAULT_POLL_INTERVAL = 5;
public function __construct(
public private(set) string $id,
public private(set) string $appId,
public private(set) ?string $userId,
public private(set) string $code,
public private(set) string $userCode,
public private(set) int $interval,
public private(set) int $polledTime,
public private(set) string $scope,
public private(set) OAuth2DeviceApproval $approval,
public private(set) int $createdTime,
public private(set) int $expiresTime
) {}
public static function fromResult(DbResult $result): OAuth2DeviceInfo {
return new OAuth2DeviceInfo(
id: $result->getString(0),
appId: $result->getString(1),
userId: $result->getStringOrNull(2),
code: $result->getString(3),
userCode: $result->getString(4),
interval: $result->getInteger(5),
polledTime: $result->getInteger(6),
scope: $result->getString(7),
approval: OAuth2DeviceApproval::tryFrom($result->getString(8)) ?? OAuth2DeviceApproval::Denied,
createdTime: $result->getInteger(9),
expiresTime: $result->getInteger(10),
);
}
public function getUserCodeDashed(int $interval = 3): string {
// how is this a stdlib function????? i mean thanks but ?????
// update: it was too good to be true, i need to trim anyway...
return trim(chunk_split($this->userCode, $interval, '-'), '-');
}
public CarbonImmutable $polledAt {
get => CarbonImmutable::createFromTimestampUTC($this->polledTime);
}
public bool $speedy {
get => ($this->polledTime + $this->interval) > time();
}
/** @var string[] */
public array $scopes {
get => explode(' ', $this->scope);
}
public bool $pending {
get => $this->approval === OAuth2DeviceApproval::Pending;
}
public bool $approved {
get => $this->approval === OAuth2DeviceApproval::Approved;
}
public bool $denied {
get => $this->approval === OAuth2DeviceApproval::Denied;
}
public CarbonImmutable $createdAt {
get => CarbonImmutable::createFromTimestampUTC($this->createdTime);
}
public bool $expired {
get => time() > $this->expiresTime;
}
public CarbonImmutable $expiresAt {
get => CarbonImmutable::createFromTimestampUTC($this->expiresTime);
}
public int $remainingLifetime {
get => max(0, $this->expiresTime - time());
}
}

View file

@ -0,0 +1,171 @@
<?php
namespace Misuzu\OAuth2;
use InvalidArgumentException;
use RuntimeException;
use Index\XString;
use Index\Db\{DbConnection,DbStatementCache};
use Misuzu\Apps\AppInfo;
use Misuzu\Users\UserInfo;
class OAuth2DevicesData {
private const USER_CODE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
private DbStatementCache $cache;
public function __construct(
private DbConnection $dbConn
) {
$this->cache = new DbStatementCache($dbConn);
}
public function getDeviceInfo(
?string $deviceId = null,
AppInfo|string|null $appInfo = null,
UserInfo|string|null|false $userInfo = false,
?string $code = null,
?string $userCode = null
): OAuth2DeviceInfo {
$selectors = [];
$values = [];
if($deviceId !== null) {
$selectors[] = 'dev_id = ?';
$values[] = $deviceId;
}
if($appInfo !== null) {
$selectors[] = 'app_id = ?';
$values[] = $appInfo instanceof AppInfo ? $appInfo->id : $appInfo;
}
if($userInfo !== false) {
if($userInfo === null) {
$selectors[] = 'user_id IS NULL';
} else {
$selectors[] = 'user_id = ?';
$values[] = $userInfo instanceof UserInfo ? $userInfo->id : $userInfo;
}
}
if($code !== null) {
$selectors[] = 'dev_code = ?';
$values[] = $code;
}
if($userCode !== null) {
$selectors[] = 'dev_user_code = ?';
$values[] = preg_replace('#[^A-Za-z2-7]#', '', strtr($userCode, ['0' => 'O', '1' => 'I', '8' => 'B']));
}
if(empty($selectors))
throw new RuntimeException('Insufficient data to do device authorisation request lookup.');
$args = 0;
$stmt = $this->cache->get(sprintf(<<<SQL
SELECT dev_id, app_id, user_id, dev_code, dev_user_code, dev_interval,
UNIX_TIMESTAMP(dev_polled), dev_scope, dev_approval,
UNIX_TIMESTAMP(dev_created), UNIX_TIMESTAMP(dev_expires)
FROM msz_oauth2_device
WHERE %s
SQL, implode(' AND ', $selectors)));
foreach($values as $value)
$stmt->nextParameter($value);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('Device authorisation request not found.');
return OAuth2DeviceInfo::fromResult($result);
}
public function createDevice(
AppInfo|string $appInfo,
string $scope,
UserInfo|string|null $userInfo = null,
?string $code = null,
?string $userCode = null
): OAuth2DeviceInfo {
$code ??= XString::random(60);
$userCode ??= XString::random(9, self::USER_CODE_CHARS);
$stmt = $this->cache->get(<<<SQL
INSERT INTO msz_oauth2_device (
app_id, user_id, dev_code, dev_user_code, dev_scope
) VALUES (?, ?, ?, ?, ?)
SQL);
$stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
$stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
$stmt->nextParameter($code);
$stmt->nextParameter($userCode);
$stmt->nextParameter($scope);
$stmt->execute();
return $this->getDeviceInfo(deviceId: (string)$stmt->lastInsertId);
}
public function deleteDevice(
OAuth2DeviceInfo|string|null $deviceInfo = null
): void {
$selectors = [];
$values = [];
if($deviceInfo !== null) {
$selectors[] = 'dev_id = ?';
$values[] = $deviceInfo instanceof OAuth2DeviceInfo ? $deviceInfo->id : $deviceInfo;
}
$query = 'DELETE FROM msz_oauth2_device';
if(!empty($selectors))
$query .= sprintf(' WHERE %s', implode(' AND ', $selectors));
$args = 0;
$stmt = $this->cache->get($query);
foreach($values as $value)
$stmt->nextParameter($value);
$stmt->execute();
}
public function setDeviceApproval(
OAuth2DeviceInfo|string $deviceInfo,
bool $approval,
UserInfo|string|null $userInfo = null
): void {
if($deviceInfo instanceof OAuth2DeviceInfo) {
if(!$deviceInfo->pending)
return;
$deviceInfo = $deviceInfo->id;
}
$stmt = $this->cache->get(<<<SQL
UPDATE msz_oauth2_device
SET dev_approval = ?,
user_id = COALESCE(user_id, ?)
WHERE dev_id = ?
AND dev_approval = 'pending'
AND user_id IS NULL
SQL);
$stmt->nextParameter($approval ? 'approved' : 'denied');
$stmt->nextParameter($approval ? ($userInfo instanceof UserInfo ? $userInfo->id : $userInfo) : null);
$stmt->nextParameter($deviceInfo);
$stmt->execute();
}
public function bumpDevicePollTime(OAuth2DeviceInfo|string $deviceInfo): void {
$stmt = $this->cache->get('UPDATE msz_oauth2_device SET dev_polled = NOW() WHERE dev_id = ?');
$stmt->nextParameter($deviceInfo instanceof OAuth2DeviceInfo ? $deviceInfo->id : $deviceInfo);
$stmt->execute();
}
public function incrementDevicePollInterval(OAuth2DeviceInfo|string $deviceInfo, int $amount = 5): void {
$stmt = $this->cache->get(<<<SQL
UPDATE msz_oauth2_device
SET dev_interval = dev_interval + ?,
dev_polled = NOW()
WHERE dev_id = ?
SQL);
$stmt->nextParameter($amount);
$stmt->nextParameter($deviceInfo instanceof OAuth2DeviceInfo ? $deviceInfo->id : $deviceInfo);
$stmt->execute();
}
public function pruneExpiredDevices(): int {
return (int)$this->dbConn->execute('DELETE FROM msz_oauth2_device WHERE dev_expires <= NOW() - INTERVAL 1 HOUR');
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace Misuzu\OAuth2;
use Carbon\CarbonImmutable;
use Index\Db\DbResult;
class OAuth2RefreshInfo {
public function __construct(
public private(set) string $id,
public private(set) string $appId,
public private(set) ?string $userId,
public private(set) ?string $accessId,
public private(set) string $token,
public private(set) string $scope,
public private(set) int $createdTime,
public private(set) int $expiresTime
) {}
public static function fromResult(DbResult $result): OAuth2RefreshInfo {
return new OAuth2RefreshInfo(
id: $result->getString(0),
appId: $result->getString(1),
userId: $result->getStringOrNull(2),
accessId: $result->getStringOrNull(3),
token: $result->getString(4),
scope: $result->getString(5),
createdTime: $result->getInteger(6),
expiresTime: $result->getInteger(7),
);
}
/** @var string[] */
public array $scopes {
get => explode(' ', $this->scope);
}
public CarbonImmutable $createdAt {
get => CarbonImmutable::createFromTimestampUTC($this->createdTime);
}
public bool $expired {
get => time() > $this->expiresTime;
}
public CarbonImmutable $expiresAt {
get => CarbonImmutable::createFromTimestampUTC($this->expiresTime);
}
public int $remainingLifetime {
get => max(0, $this->expiresTime - time());
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace Misuzu\OAuth2;
enum OAuth2RefreshInfoGetField {
case Id;
case Access;
case Token;
}

View file

@ -0,0 +1,196 @@
<?php
namespace Misuzu\OAuth2;
use RuntimeException;
use RPCii\Server\{RpcHandler,RpcHandlerCommon,RpcAction};
final class OAuth2RpcHandler implements RpcHandler {
use RpcHandlerCommon;
public function __construct(
private OAuth2Context $oauth2Ctx
) {}
/**
* @return array{
* method: 'basic',
* error?: 'app'|'secret',
* type?: 'confapp'|'pubapp',
* app?: string,
* scope?: string[]
* }
*/
#[RpcAction('hanyuu:oauth2:attemptAppAuth')]
public function procAttemptAppAuth(string $remoteAddr, string $clientId, string $clientSecret = ''): array {
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false);
} catch(RuntimeException $ex) {
return ['method' => 'basic', 'error' => 'app'];
}
$authed = false;
if($clientSecret !== '') {
// TODO: rate limiting
if(!$appInfo->verifyClientSecret($clientSecret))
return ['method' => 'basic', 'error' => 'secret'];
$authed = true;
}
return [
'method' => 'basic',
'type' => $authed ? 'confapp' : 'pubapp',
'app' => $appInfo->id,
'scope' => ['oauth2'],
];
}
/**
* @return array{
* method: 'bearer',
* error?: 'token'|'expired',
* type?: 'app'|'user',
* app?: string,
* user?: string,
* scope?: string[],
* expires?: int,
* }
*/
#[RpcAction('hanyuu:oauth2:attemptBearerAuth')]
public function procAttemptBearerAuth(string $remoteAddr, string $token): array {
try {
$tokenInfo = $this->oauth2Ctx->tokens->getAccessInfo($token, OAuth2AccessInfoGetField::Token);
} catch(RuntimeException $ex) {
return ['method' => 'bearer', 'error' => 'token'];
}
if($tokenInfo->expired)
return ['method' => 'bearer', 'error' => 'expired'];
return [
'method' => 'bearer',
'type' => empty($tokenInfo->userId) ? 'app' : 'user',
'app' => $tokenInfo->appId,
'user' => $tokenInfo->userId ?? '0',
'scope' => $tokenInfo->scopes,
'expires' => $tokenInfo->expiresTime,
];
}
/**
* @return array{
* device_code: string,
* user_code: string,
* verification_uri: string,
* verification_uri_complete: string,
* expires_in?: int,
* interval?: int
* }|array{ error: string, error_description: string }
*/
#[RpcAction('hanyuu:oauth2:createAuthoriseRequest')]
public function procCreateAuthoriseRequest(string $appId, ?string $scope = null): array {
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(appId: $appId, deleted: false);
} catch(RuntimeException $ex) {
return [
'error' => 'invalid_client',
'error_description' => 'No application has been registered with this client ID.',
];
}
return $this->oauth2Ctx->createDeviceAuthorisationRequest($appInfo, $scope);
}
/**
* @return array{
* access_token: string,
* token_type: 'Bearer',
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* }|array{ error: string, error_description: string }
*/
#[RpcAction('hanyuu:oauth2:createBearerToken:authorisationCode')]
public function procCreateBearerTokenAuthzCode(string $appId, bool $isAuthed, string $code, string $codeVerifier): array {
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(appId: $appId, deleted: false);
} catch(RuntimeException $ex) {
return [
'error' => 'invalid_client',
'error_description' => 'No application has been registered with this client ID.',
];
}
return $this->oauth2Ctx->redeemAuthorisationCode($appInfo, $isAuthed, $code, $codeVerifier);
}
/**
* @return array{
* access_token: string,
* token_type: 'Bearer',
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* }|array{ error: string, error_description: string }
*/
#[RpcAction('hanyuu:oauth2:createBearerToken:refreshToken')]
public function procCreateBearerTokenRefreshToken(string $appId, bool $isAuthed, string $refreshToken, ?string $scope = null): array {
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(appId: $appId, deleted: false);
} catch(RuntimeException $ex) {
return [
'error' => 'invalid_client',
'error_description' => 'No application has been registered with this client ID.',
];
}
return $this->oauth2Ctx->redeemRefreshToken($appInfo, $isAuthed, $refreshToken, $scope);
}
/**
* @return array{
* access_token: string,
* token_type: 'Bearer',
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* }|array{ error: string, error_description: string }
*/
#[RpcAction('hanyuu:oauth2:createBearerToken:clientCredentials')]
public function procCreateBearerTokenClientCreds(string $appId, bool $isAuthed, ?string $scope = null): array {
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(appId: $appId, deleted: false);
} catch(RuntimeException $ex) {
return [
'error' => 'invalid_client',
'error_description' => 'No application has been registered with this client ID.',
];
}
return $this->oauth2Ctx->redeemClientCredentials($appInfo, $isAuthed, $scope);
}
/**
* @return array{
* access_token: string,
* token_type: 'Bearer',
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* }|array{ error: string, error_description: string }
*/
#[RpcAction('hanyuu:oauth2:createBearerToken:deviceCode')]
public function procCreateBearerTokenDeviceCode(string $appId, bool $isAuthed, string $deviceCode): array {
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(appId: $appId, deleted: false);
} catch(RuntimeException $ex) {
return [
'error' => 'invalid_client',
'error_description' => 'No application has been registered with this client ID.',
];
}
return $this->oauth2Ctx->redeemDeviceCode($appInfo, $isAuthed, $deviceCode);
}
}

View file

@ -0,0 +1,207 @@
<?php
namespace Misuzu\OAuth2;
use InvalidArgumentException;
use RuntimeException;
use Index\XString;
use Index\Db\{DbConnection,DbStatementCache};
use Misuzu\Apps\AppInfo;
use Misuzu\Users\UserInfo;
class OAuth2TokensData {
private DbStatementCache $cache;
public function __construct(
private DbConnection $dbConn
) {
$this->cache = new DbStatementCache($dbConn);
}
public function getAccessInfo(string $value, OAuth2AccessInfoGetField $field): OAuth2AccessInfo {
if($field === OAuth2AccessInfoGetField::Id)
$field = 'acc_id';
elseif($field === OAuth2AccessInfoGetField::Token)
$field = 'acc_token';
else
throw new InvalidArgumentException('$field is not a valid mode');
$stmt = $this->cache->get(sprintf(<<<SQL
SELECT acc_id, app_id, user_id, acc_token, acc_scope,
UNIX_TIMESTAMP(acc_created), UNIX_TIMESTAMP(acc_expires)
FROM msz_oauth2_access
WHERE %s = ?
SQL, $field));
$stmt->nextParameter($value);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('Access info not found.');
return OAuth2AccessInfo::fromResult($result);
}
public function createAccess(
AppInfo|string $appInfo,
UserInfo|string|null $userInfo = null,
?string $token = null,
string $scope = '',
?int $lifetime = null
): OAuth2AccessInfo {
$token ??= XString::random(80);
$stmt = $this->cache->get(<<<SQL
INSERT INTO msz_oauth2_access (
app_id, user_id, acc_token, acc_scope,
acc_expires
) VALUES (
?, ?, ?, ?,
IF(?, NOW() + INTERVAL ? SECOND, DEFAULT(acc_expires))
)
SQL);
$stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
$stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
$stmt->nextParameter($token);
$stmt->nextParameter($scope);
$stmt->nextParameter($lifetime === null ? 0 : 1);
$stmt->nextParameter($lifetime);
$stmt->execute();
return $this->getAccessInfo((string)$stmt->lastInsertId, OAuth2AccessInfoGetField::Id);
}
public function deleteAccess(
OAuth2AccessInfo|string|null $accessInfo = null,
AppInfo|string|null $appInfo = null,
UserInfo|string|null $userInfo = null,
): void {
$selectors = [];
$values = [];
if($accessInfo !== null) {
$selectors[] = 'acc_id = ?';
$values[] = $accessInfo instanceof OAuth2AccessInfo ? $accessInfo->id : $accessInfo;
}
if($appInfo !== null) {
$selectors[] = 'app_id = ?';
$values[] = $appInfo instanceof AppInfo ? $appInfo->id : $appInfo;
}
if($userInfo !== null) {
$selectors[] = 'user_id = ?';
$values[] = $userInfo instanceof UserInfo ? $userInfo->id : $userInfo;
}
$query = 'DELETE FROM msz_oauth2_access';
if(!empty($selectors))
$query .= sprintf(' WHERE %s', implode(' AND ', $selectors));
$stmt = $this->cache->get($query);
foreach($values as $value)
$stmt->nextParameter($value);
$stmt->execute();
}
public function pruneExpiredAccess(): int {
return (int)$this->dbConn->execute('DELETE FROM msz_oauth2_access WHERE acc_expires <= NOW() - INTERVAL 1 DAY');
}
public function getRefreshInfo(object|string $value, OAuth2RefreshInfoGetField $field): OAuth2RefreshInfo {
if($field === OAuth2RefreshInfoGetField::Id) {
$field = 'ref_id';
} elseif($field === OAuth2RefreshInfoGetField::Access) {
$field = 'acc_id';
if($value instanceof OAuth2AccessInfo)
$value = $value->id;
} elseif($field === OAuth2RefreshInfoGetField::Token) {
$field = 'ref_token';
} else
throw new InvalidArgumentException('$field is not a valid mode');
if(!is_string($value))
throw new InvalidArgumentException('$value must be a string');
$stmt = $this->cache->get(sprintf(<<<SQL
SELECT ref_id, app_id, user_id, acc_id, ref_token, ref_scope,
UNIX_TIMESTAMP(ref_created), UNIX_TIMESTAMP(ref_expires)
FROM msz_oauth2_refresh
WHERE %s = ?
SQL, $field));
$stmt->nextParameter($value);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('Refresh info not found.');
return OAuth2RefreshInfo::fromResult($result);
}
public function createRefresh(
AppInfo|string $appInfo,
OAuth2AccessInfo|string|null $accessInfo,
UserInfo|string|null $userInfo = null,
?string $token = null,
string $scope = '',
?int $lifetime = null
): OAuth2RefreshInfo {
$token ??= XString::random(120);
$stmt = $this->cache->get(<<<SQL
INSERT INTO msz_oauth2_refresh (
app_id, user_id, acc_id, ref_token, ref_scope,
ref_expires
) VALUES (
?, ?, ?, ?, ?,
IF(?, NOW() + INTERVAL ? SECOND, DEFAULT(ref_expires))
)
SQL);
$stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
$stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
$stmt->nextParameter($accessInfo instanceof OAuth2AccessInfo ? $accessInfo->id : $accessInfo);
$stmt->nextParameter($token);
$stmt->nextParameter($scope);
$stmt->nextParameter($lifetime === null ? 0 : 1);
$stmt->nextParameter($lifetime);
$stmt->execute();
return $this->getRefreshInfo((string)$stmt->lastInsertId, OAuth2RefreshInfoGetField::Id);
}
public function deleteRefresh(
OAuth2RefreshInfo|string|null $refreshInfo = null,
AppInfo|string|null $appInfo = null,
UserInfo|string|null $userInfo = null,
OAuth2AccessInfo|string|null $accessInfo = null
): void {
$selectors = [];
$values = [];
if($refreshInfo !== null) {
$selectors[] = 'ref_id = ?';
$values[] = $refreshInfo instanceof OAuth2RefreshInfo ? $refreshInfo->id : $refreshInfo;
}
if($appInfo !== null) {
$selectors[] = 'app_id = ?';
$values[] = $appInfo instanceof AppInfo ? $appInfo->id : $appInfo;
}
if($userInfo !== null) {
$selectors[] = 'user_id = ?';
$values[] = $userInfo instanceof UserInfo ? $userInfo->id : $userInfo;
}
if($accessInfo !== null) {
$selectors[] = 'acc_id = ?';
$values[] = $accessInfo instanceof OAuth2AccessInfo ? $accessInfo->id : $accessInfo;
}
$query = 'DELETE FROM msz_oauth2_refresh';
if(!empty($selectors))
$query .= sprintf(' WHERE %s', implode(' AND ', $selectors));
$stmt = $this->cache->get($query);
foreach($values as $value)
$stmt->nextParameter($value);
$stmt->execute();
}
public function pruneExpiredRefresh(): int {
return (int)$this->dbConn->execute('DELETE FROM msz_oauth2_refresh WHERE ref_expires <= NOW()');
}
}

View file

@ -0,0 +1,430 @@
<?php
namespace Misuzu\OAuth2;
use InvalidArgumentException;
use RuntimeException;
use Index\Http\{FormHttpContent,HttpResponseBuilder,HttpRequest};
use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler,RouteHandlerCommon};
use Index\Urls\UrlRegistry;
use Misuzu\{CSRF,SiteInfo,Template};
use Misuzu\Auth\AuthInfo;
use Misuzu\Users\UsersContext;
final class OAuth2WebRoutes implements RouteHandler {
use RouteHandlerCommon;
public function __construct(
private OAuth2Context $oauth2Ctx,
private UsersContext $usersCtx,
private UrlRegistry $urls,
private SiteInfo $siteInfo,
private AuthInfo $authInfo
) {
}
#[HttpGet('/oauth2/authorise')]
#[HttpGet('/oauth2/authorize')]
public function getAuthorise(HttpResponseBuilder $response, HttpRequest $request): string {
if(!$this->authInfo->loggedIn)
return Template::renderRaw('oauth2.login', [
'login_url' => $this->urls->format('auth-login'),
'register_url' => $this->urls->format('auth-register'),
]);
return Template::renderRaw('oauth2.authorise');
}
/**
* @return int|array{
* error: 'auth'|'csrf'|'method'|'length'|'client'|'scope'|'format'|'required'|'authorise',
* scope?: string,
* reason?: string,
* }|array{
* code: string,
* redirect: string,
* }
*/
#[HttpPost('/oauth2/authorise')]
public function postAuthorise(HttpResponseBuilder $response, HttpRequest $request): int|array {
if(!($request->content instanceof FormHttpContent))
return 400;
// TODO: RATE LIMITING
if(!$this->authInfo->loggedIn)
return ['error' => 'auth'];
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
return ['error' => 'csrf'];
$response->setHeader('X-CSRF-Token', CSRF::token());
$codeChallengeMethod = 'plain';
if($request->content->hasParam('ccm')) {
$codeChallengeMethod = $request->content->getParam('ccm');
if(!in_array($codeChallengeMethod, ['plain', 'S256']))
return ['error' => 'method'];
}
$codeChallenge = $request->content->getParam('cc');
$codeChallengeLength = strlen($codeChallenge);
if($codeChallengeMethod === 'S256') {
if($codeChallengeLength !== 43)
return ['error' => 'length'];
} else {
if($codeChallengeLength < 43 || $codeChallengeLength > 128)
return ['error' => 'length'];
}
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
clientId: (string)$request->content->getParam('client'),
deleted: false
);
} catch(RuntimeException $ex) {
return ['error' => 'client'];
}
if($request->content->hasParam('scope')) {
$scope = [];
$scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, (string)$request->content->getParam('scope'));
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo))
return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
$scope[] = $scopeInfo->string;
}
$scope = implode(' ', $scope);
} else $scope = '';
if($request->content->hasParam('redirect')) {
$redirectUri = (string)$request->content->getParam('redirect');
$redirectUriId = $this->oauth2Ctx->appsCtx->apps->getAppUriId($appInfo, $redirectUri);
if($redirectUriId === null)
return ['error' => 'format'];
} else {
$uriInfos = $this->oauth2Ctx->appsCtx->apps->getAppUriInfos($appInfo);
if(count($uriInfos) !== 1)
return ['error' => 'required'];
$uriInfo = array_pop($uriInfos);
$redirectUri = $uriInfo->string;
$redirectUriId = $uriInfo->id;
}
try {
$authsInfo = $this->oauth2Ctx->authorisations->createAuthorisation(
$appInfo,
$this->authInfo->userInfo,
$redirectUriId,
$codeChallenge,
$codeChallengeMethod,
$scope
);
} catch(RuntimeException $ex) {
return ['error' => 'authorise', 'detail' => $ex->getMessage()];
}
return [
'code' => $authsInfo->code,
'redirect' => $redirectUri,
];
}
/**
* @return array{
* error: 'auth'|'csrf'|'client'|'scope'|'format'|'required',
* scope?: string,
* reason?: string,
* }|array{
* app: array{
* name: string,
* summary: string,
* trusted: bool,
* links: array{ title: string, display: string, uri: string }[]
* },
* user: array{
* name: string,
* colour: string,
* profile_uri: string,
* avatar_uri: string,
* guise?: array{
* name: string,
* colour: string,
* profile_uri: string,
* revert_uri: string,
* avatar_uri: string,
* },
* },
* scope: string[],
* }
*/
#[HttpGet('/oauth2/resolve-authorise-app')]
public function getResolveAuthorise(HttpResponseBuilder $response, HttpRequest $request): array {
// TODO: RATE LIMITING
if(!$this->authInfo->loggedIn)
return ['error' => 'auth'];
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
return ['error' => 'csrf'];
$response->setHeader('X-CSRF-Token', CSRF::token());
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
clientId: (string)$request->getParam('client'),
deleted: false
);
} catch(RuntimeException $ex) {
return ['error' => 'client'];
}
if($request->hasParam('redirect')) {
$redirectUri = (string)$request->getParam('redirect');
if($this->oauth2Ctx->appsCtx->apps->getAppUriId($appInfo, $redirectUri) === null)
return ['error' => 'format'];
} else {
$uriInfos = $this->oauth2Ctx->appsCtx->apps->getAppUriInfos($appInfo);
if(count($uriInfos) !== 1)
return ['error' => 'required'];
}
$scope = [];
if($request->hasParam('scope')) {
$scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, (string)$request->getParam('scope'));
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo))
return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
$scope[] = $scopeInfo->summary;
}
}
$result = [
'app' => [
'name' => $appInfo->name,
'summary' => $appInfo->summary,
'trusted' => $appInfo->trusted,
'links' => [
['title' => 'Website', 'display' => $appInfo->websiteForDisplay, 'uri' => $appInfo->website],
],
],
'user' => [
'name' => $this->authInfo->userInfo->name,
'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->userInfo),
'profile_uri' => $this->siteInfo->url . $this->urls->format('user-profile', ['user' => $this->authInfo->userInfo->id]),
'avatar_uri' => $this->siteInfo->url . $this->urls->format('user-avatar', ['user' => $this->authInfo->userInfo->id, 'res' => 120]),
],
'scope' => $scope,
];
if($this->authInfo->isImpersonating)
$result['user']['guise'] = [
'name' => $this->authInfo->realUserInfo->name,
'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->realUserInfo),
'profile_uri' => $this->siteInfo->url . $this->urls->format('user-profile', ['user' => $this->authInfo->realUserInfo->id]),
'revert_uri' => $this->siteInfo->url . $this->urls->format('auth-revert', ['csrf' => CSRF::token()]),
'avatar_uri' => $this->siteInfo->url . $this->urls->format('user-avatar', ['user' => $this->authInfo->realUserInfo->id, 'res' => 60]),
];
return $result;
}
#[HttpGet('/oauth2/verify')]
public function getVerify(HttpResponseBuilder $response, HttpRequest $request): string {
if(!$this->authInfo->loggedIn)
return Template::renderRaw('oauth2.login', [
'login_url' => $this->urls->format('auth-login'),
'register_url' => $this->urls->format('auth-register'),
]);
return Template::renderRaw('oauth2.verify');
}
/**
* @return int|array{
* error: 'auth'|'csrf'|'invalid'|'code'|'expired'|'approval'|'code'|'scope',
* scope?: string,
* reason?: string,
* }|array{
* approval: 'approved'|'denied',
* }
*/
#[HttpPost('/oauth2/verify')]
public function postVerify(HttpResponseBuilder $response, HttpRequest $request): int|array {
if(!($request->content instanceof FormHttpContent))
return 400;
// TODO: RATE LIMITING
if(!$this->authInfo->loggedIn)
return ['error' => 'auth'];
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
return ['error' => 'csrf'];
$response->setHeader('X-CSRF-Token', CSRF::token());
$approve = (string)$request->content->getParam('approve');
if(!in_array($approve, ['yes', 'no']))
return ['error' => 'invalid'];
try {
$deviceInfo = $this->oauth2Ctx->devices->getDeviceInfo(
userCode: (string)$request->content->getParam('code')
);
} catch(RuntimeException $ex) {
return ['error' => 'code'];
}
if($deviceInfo->expired)
return ['error' => 'expired'];
if(!$deviceInfo->pending)
return ['error' => 'approval'];
$approved = $approve === 'yes';
$error = null;
if($approved) {
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
appId: $deviceInfo->appId,
deleted: false
);
} catch(RuntimeException $ex) {
return ['error' => 'code'];
}
$scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, $deviceInfo->scope);
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo)) {
$approved = false;
$error = ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
break;
}
$scope[] = $scopeInfo->getSummary();
}
}
$this->oauth2Ctx->devices->setDeviceApproval($deviceInfo, $approved, $this->authInfo->userInfo);
if($error !== null)
return $error;
return [
'approval' => $approved ? 'approved' : 'denied',
];
}
/**
* @return array{
* error: 'auth'|'csrf'|'code'|'expired'|'approval'|'scope',
* scope?: string,
* reason?: string,
* }|array{
* req: array{
* code: string,
* },
* app: array{
* name: string,
* summary: string,
* trusted: bool,
* links: array{ title: string, display: string, uri: string }[]
* },
* user: array{
* name: string,
* colour: string,
* profile_uri: string,
* avatar_uri: string,
* guise?: array{
* name: string,
* colour: string,
* profile_uri: string,
* revert_uri: string,
* avatar_uri: string,
* },
* },
* scope: string[],
* }
*/
#[HttpGet('/oauth2/resolve-verify')]
public function getResolveVerify(HttpResponseBuilder $response, HttpRequest $request) {
// TODO: RATE LIMITING
if(!$this->authInfo->loggedIn)
return ['error' => 'auth'];
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
return ['error' => 'csrf'];
$response->setHeader('X-CSRF-Token', CSRF::token());
try {
$deviceInfo = $this->oauth2Ctx->devices->getDeviceInfo(userCode: (string)$request->getParam('code'));
} catch(RuntimeException $ex) {
return ['error' => 'code'];
}
if($deviceInfo->expired)
return ['error' => 'expired'];
if(!$deviceInfo->pending)
return ['error' => 'approval'];
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
appId: $deviceInfo->appId,
deleted: false
);
} catch(RuntimeException $ex) {
return ['error' => 'code'];
}
$scope = [];
$scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, $deviceInfo->scope);
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo))
return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
$scope[] = $scopeInfo->getSummary();
}
$result = [
'req' => [
'code' => $deviceInfo->userCode,
],
'app' => [
'name' => $appInfo->name,
'summary' => $appInfo->summary,
'trusted' => $appInfo->trusted,
'links' => [
['title' => 'Website', 'display' => $appInfo->websiteForDisplay, 'uri' => $appInfo->website],
],
],
'scope' => $scope,
'user' => [
'name' => $this->authInfo->userInfo->name,
'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->userInfo),
'profile_uri' => $this->siteInfo->url . $this->urls->format('user-profile', ['user' => $this->authInfo->userInfo->id]),
'avatar_uri' => $this->siteInfo->url . $this->urls->format('user-avatar', ['user' => $this->authInfo->userInfo->id, 'res' => 120]),
],
];
if($this->authInfo->isImpersonating)
$result['user']['guise'] = [
'name' => $this->authInfo->realUserInfo->name,
'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->realUserInfo),
'profile_uri' => $this->siteInfo->url . $this->urls->format('user-profile', ['user' => $this->authInfo->realUserInfo->id]),
'revert_uri' => $this->siteInfo->url . $this->urls->format('auth-revert', ['csrf' => CSRF::token()]),
'avatar_uri' => $this->siteInfo->url . $this->urls->format('user-avatar', ['user' => $this->authInfo->realUserInfo->id, 'res' => 60]),
];
return $result;
}
}

Some files were not shown because too many files have changed in this diff Show more