Merged OAuth2 handling into Misuzu.
This commit is contained in:
parent
1994a9892d
commit
534e947522
115 changed files with 4556 additions and 77 deletions
VERSIONbuild.js
assets
misuzu.js
oauth2.css
appinfo.cssapproval.cssauthorise.cssbanner.cssdevice.csserror.cssloading.cssmain.cssscope.csssimplehead.cssuserhead.css
oauth2.js
config
database
2025_02_01_181944_create_apps_tables.php2025_02_01_182753_create_scopes_tables.php2025_02_01_183150_create_oauth_tables.php
public-legacy
public
images
circle-check-regular.svgcircle-check-solid.svgcircle-exclamation-solid.svgcircle-question-solid.svgcircle-regular.svgcircle-xmark-solid.svgellipsis-solid.svgexclamation-solid.svgflashii.svgglobe-solid.svgmobile-screen-solid.svguser-lock-solid.svg
index.phpsrc
ATProto
Apps
AppInfo.phpAppScopesInfo.phpAppType.phpAppUriInfo.phpAppsContext.phpAppsData.phpScopeInfo.phpScopeInfoGetField.phpScopesData.php
AuditLog
Auth
Changelog
Comments
Counters
Emoticons
Forum
ForumCategoriesData.phpForumCategoriesRoutes.phpForumPostsData.phpForumPostsRoutes.phpForumTopicRedirectsData.phpForumTopicsData.phpForumTopicsRoutes.php
Home
Messages
MisuzuContext.phpNews
OAuth2
OAuth2AccessInfo.phpOAuth2AccessInfoGetField.phpOAuth2ApiRoutes.phpOAuth2AuthorisationData.phpOAuth2AuthorisationInfo.phpOAuth2Context.phpOAuth2DeviceApproval.phpOAuth2DeviceInfo.phpOAuth2DevicesData.phpOAuth2RefreshInfo.phpOAuth2RefreshInfoGetField.phpOAuth2RpcHandler.phpOAuth2TokensData.phpOAuth2WebRoutes.php
2
VERSION
2
VERSION
|
@ -1 +1 @@
|
|||
20250130.2
|
||||
20250201
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
43
assets/oauth2.css/appinfo.css
Normal file
43
assets/oauth2.css/appinfo.css
Normal 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;
|
||||
}
|
35
assets/oauth2.css/approval.css
Normal file
35
assets/oauth2.css/approval.css
Normal 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;
|
||||
}
|
72
assets/oauth2.css/authorise.css
Normal file
72
assets/oauth2.css/authorise.css
Normal 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;
|
||||
}
|
21
assets/oauth2.css/banner.css
Normal file
21
assets/oauth2.css/banner.css
Normal 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;
|
||||
}
|
||||
|
24
assets/oauth2.css/device.css
Normal file
24
assets/oauth2.css/device.css
Normal 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;
|
||||
}
|
3
assets/oauth2.css/error.css
Normal file
3
assets/oauth2.css/error.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.oauth2-errorbody p {
|
||||
margin: .5em 1em;
|
||||
}
|
36
assets/oauth2.css/loading.css
Normal file
36
assets/oauth2.css/loading.css
Normal 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;
|
||||
}
|
97
assets/oauth2.css/main.css
Normal file
97
assets/oauth2.css/main.css
Normal 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;
|
38
assets/oauth2.css/scope.css
Normal file
38
assets/oauth2.css/scope.css
Normal 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;
|
||||
}
|
30
assets/oauth2.css/simplehead.css
Normal file
30
assets/oauth2.css/simplehead.css
Normal 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;
|
||||
}
|
64
assets/oauth2.css/userhead.css
Normal file
64
assets/oauth2.css/userhead.css
Normal 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;
|
||||
}
|
30
assets/oauth2.js/app/info.jsx
Normal file
30
assets/oauth2.js/app/info.jsx
Normal 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; },
|
||||
};
|
||||
};
|
33
assets/oauth2.js/app/scope.jsx
Normal file
33
assets/oauth2.js/app/scope.jsx
Normal 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; },
|
||||
};
|
||||
};
|
232
assets/oauth2.js/authorise.js
Normal file
232
assets/oauth2.js/authorise.js
Normal 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
24
assets/oauth2.js/csrf.js
Normal 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;
|
||||
},
|
||||
};
|
||||
})();
|
57
assets/oauth2.js/header/header.js
Normal file
57
assets/oauth2.js/header/header.js
Normal 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,
|
||||
};
|
||||
};
|
53
assets/oauth2.js/header/user.jsx
Normal file
53
assets/oauth2.js/header/user.jsx
Normal 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;
|
||||
},
|
||||
};
|
||||
};
|
84
assets/oauth2.js/loading.jsx
Normal file
84
assets/oauth2.js/loading.jsx
Normal 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
10
assets/oauth2.js/main.js
Normal 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
170
assets/oauth2.js/utility.js
Normal 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
178
assets/oauth2.js/verify.js
Normal 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
117
assets/oauth2.js/xhr.js
Normal 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),
|
||||
};
|
||||
})();
|
2
build.js
2
build.js
|
@ -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', },
|
||||
|
|
|
@ -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
|
||||
|
|
54
database/2025_02_01_181944_create_apps_tables.php
Normal file
54
database/2025_02_01_181944_create_apps_tables.php
Normal 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);
|
||||
}
|
||||
}
|
43
database/2025_02_01_182753_create_scopes_tables.php
Normal file
43
database/2025_02_01_182753_create_scopes_tables.php
Normal 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);
|
||||
}
|
||||
}
|
136
database/2025_02_01_183150_create_oauth_tables.php
Normal file
136
database/2025_02_01_183150_create_oauth_tables.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'] : '';
|
||||
|
|
|
@ -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 = [];
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 = [];
|
||||
|
|
1
public/images/circle-check-regular.svg
Normal file
1
public/images/circle-check-regular.svg
Normal 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 |
1
public/images/circle-check-solid.svg
Normal file
1
public/images/circle-check-solid.svg
Normal 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 |
1
public/images/circle-exclamation-solid.svg
Normal file
1
public/images/circle-exclamation-solid.svg
Normal 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 |
1
public/images/circle-question-solid.svg
Normal file
1
public/images/circle-question-solid.svg
Normal 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 |
1
public/images/circle-regular.svg
Normal file
1
public/images/circle-regular.svg
Normal 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 |
1
public/images/circle-xmark-solid.svg
Normal file
1
public/images/circle-xmark-solid.svg
Normal 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 |
1
public/images/ellipsis-solid.svg
Normal file
1
public/images/ellipsis-solid.svg
Normal 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 |
1
public/images/exclamation-solid.svg
Normal file
1
public/images/exclamation-solid.svg
Normal 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 |
1
public/images/flashii.svg
Normal file
1
public/images/flashii.svg
Normal 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 |
1
public/images/globe-solid.svg
Normal file
1
public/images/globe-solid.svg
Normal 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 |
1
public/images/mobile-screen-solid.svg
Normal file
1
public/images/mobile-screen-solid.svg
Normal 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 |
1
public/images/user-lock-solid.svg
Normal file
1
public/images/user-lock-solid.svg
Normal 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 |
|
@ -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.
|
||||
|
|
|
@ -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
87
src/Apps/AppInfo.php
Normal 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);
|
||||
}
|
||||
}
|
23
src/Apps/AppScopesInfo.php
Normal file
23
src/Apps/AppScopesInfo.php
Normal 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
8
src/Apps/AppType.php
Normal 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
31
src/Apps/AppUriInfo.php
Normal 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
71
src/Apps/AppsContext.php
Normal 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
213
src/Apps/AppsData.php
Normal 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
39
src/Apps/ScopeInfo.php
Normal 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);
|
||||
}
|
||||
}
|
7
src/Apps/ScopeInfoGetField.php
Normal file
7
src/Apps/ScopeInfoGetField.php
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
namespace Misuzu\Apps;
|
||||
|
||||
enum ScopeInfoGetField {
|
||||
case Id;
|
||||
case String;
|
||||
}
|
57
src/Apps/ScopesData.php
Normal file
57
src/Apps/ScopesData.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -89,7 +89,7 @@ class AuditLogData {
|
|||
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->getResult()->getIterator(AuditLogInfo::fromResult(...));
|
||||
return $stmt->getResultIterator(AuditLogInfo::fromResult(...));
|
||||
}
|
||||
|
||||
/** @param mixed[] $params */
|
||||
|
|
|
@ -39,7 +39,7 @@ class AuthInfo {
|
|||
$this->setInfo(AuthTokenInfo::empty());
|
||||
}
|
||||
|
||||
public bool $isLoggedIn {
|
||||
public bool $loggedIn {
|
||||
get => $this->userInfo !== null;
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -43,7 +43,7 @@ class CountersData {
|
|||
$pagination->addToStatement($stmt);
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->getResult()->getIterator(CounterInfo::fromResult(...));
|
||||
return $stmt->getResultIterator(CounterInfo::fromResult(...));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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')))
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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')))
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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')))
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -151,7 +151,7 @@ class MessagesData {
|
|||
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->getResult()->getIterator(MessageInfo::fromResult(...));
|
||||
return $stmt->getResultIterator(MessageInfo::fromResult(...));
|
||||
}
|
||||
|
||||
public function getMessageInfo(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 {
|
||||
|
|
52
src/OAuth2/OAuth2AccessInfo.php
Normal file
52
src/OAuth2/OAuth2AccessInfo.php
Normal 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());
|
||||
}
|
||||
}
|
7
src/OAuth2/OAuth2AccessInfoGetField.php
Normal file
7
src/OAuth2/OAuth2AccessInfoGetField.php
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
namespace Misuzu\OAuth2;
|
||||
|
||||
enum OAuth2AccessInfoGetField {
|
||||
case Id;
|
||||
case Token;
|
||||
}
|
215
src/OAuth2/OAuth2ApiRoutes.php
Normal file
215
src/OAuth2/OAuth2ApiRoutes.php
Normal 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.',
|
||||
]);
|
||||
}
|
||||
}
|
113
src/OAuth2/OAuth2AuthorisationData.php
Normal file
113
src/OAuth2/OAuth2AuthorisationData.php
Normal 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');
|
||||
}
|
||||
}
|
70
src/OAuth2/OAuth2AuthorisationInfo.php
Normal file
70
src/OAuth2/OAuth2AuthorisationInfo.php
Normal 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());
|
||||
}
|
||||
}
|
398
src/OAuth2/OAuth2Context.php
Normal file
398
src/OAuth2/OAuth2Context.php
Normal 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);
|
||||
}
|
||||
}
|
8
src/OAuth2/OAuth2DeviceApproval.php
Normal file
8
src/OAuth2/OAuth2DeviceApproval.php
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
namespace Misuzu\OAuth2;
|
||||
|
||||
enum OAuth2DeviceApproval: string {
|
||||
case Pending = 'pending';
|
||||
case Approved = 'approved';
|
||||
case Denied = 'denied';
|
||||
}
|
87
src/OAuth2/OAuth2DeviceInfo.php
Normal file
87
src/OAuth2/OAuth2DeviceInfo.php
Normal 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());
|
||||
}
|
||||
}
|
171
src/OAuth2/OAuth2DevicesData.php
Normal file
171
src/OAuth2/OAuth2DevicesData.php
Normal 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');
|
||||
}
|
||||
}
|
52
src/OAuth2/OAuth2RefreshInfo.php
Normal file
52
src/OAuth2/OAuth2RefreshInfo.php
Normal 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());
|
||||
}
|
||||
}
|
8
src/OAuth2/OAuth2RefreshInfoGetField.php
Normal file
8
src/OAuth2/OAuth2RefreshInfoGetField.php
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
namespace Misuzu\OAuth2;
|
||||
|
||||
enum OAuth2RefreshInfoGetField {
|
||||
case Id;
|
||||
case Access;
|
||||
case Token;
|
||||
}
|
196
src/OAuth2/OAuth2RpcHandler.php
Normal file
196
src/OAuth2/OAuth2RpcHandler.php
Normal 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);
|
||||
}
|
||||
}
|
207
src/OAuth2/OAuth2TokensData.php
Normal file
207
src/OAuth2/OAuth2TokensData.php
Normal 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()');
|
||||
}
|
||||
}
|
430
src/OAuth2/OAuth2WebRoutes.php
Normal file
430
src/OAuth2/OAuth2WebRoutes.php
Normal 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
Loading…
Add table
Add a link
Reference in a new issue