diff --git a/VERSION b/VERSION index c7de632f..50ae9039 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -20250130.2 +20250201 diff --git a/assets/misuzu.js/utility.js b/assets/misuzu.js/utility.js index cb845f2f..cf35cc39 100644 --- a/assets/misuzu.js/utility.js +++ b/assets/misuzu.js/utility.js @@ -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; } } diff --git a/assets/oauth2.css/appinfo.css b/assets/oauth2.css/appinfo.css new file mode 100644 index 00000000..5dd35da3 --- /dev/null +++ b/assets/oauth2.css/appinfo.css @@ -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; +} diff --git a/assets/oauth2.css/approval.css b/assets/oauth2.css/approval.css new file mode 100644 index 00000000..598a10b6 --- /dev/null +++ b/assets/oauth2.css/approval.css @@ -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; +} diff --git a/assets/oauth2.css/authorise.css b/assets/oauth2.css/authorise.css new file mode 100644 index 00000000..b04a03dc --- /dev/null +++ b/assets/oauth2.css/authorise.css @@ -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; +} diff --git a/assets/oauth2.css/banner.css b/assets/oauth2.css/banner.css new file mode 100644 index 00000000..cff6f674 --- /dev/null +++ b/assets/oauth2.css/banner.css @@ -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; +} + diff --git a/assets/oauth2.css/device.css b/assets/oauth2.css/device.css new file mode 100644 index 00000000..0747d7dd --- /dev/null +++ b/assets/oauth2.css/device.css @@ -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; +} diff --git a/assets/oauth2.css/error.css b/assets/oauth2.css/error.css new file mode 100644 index 00000000..41f3b681 --- /dev/null +++ b/assets/oauth2.css/error.css @@ -0,0 +1,3 @@ +.oauth2-errorbody p { + margin: .5em 1em; +} diff --git a/assets/oauth2.css/loading.css b/assets/oauth2.css/loading.css new file mode 100644 index 00000000..925d9471 --- /dev/null +++ b/assets/oauth2.css/loading.css @@ -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; +} diff --git a/assets/oauth2.css/main.css b/assets/oauth2.css/main.css new file mode 100644 index 00000000..cf9e4bce --- /dev/null +++ b/assets/oauth2.css/main.css @@ -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; diff --git a/assets/oauth2.css/scope.css b/assets/oauth2.css/scope.css new file mode 100644 index 00000000..84a7b221 --- /dev/null +++ b/assets/oauth2.css/scope.css @@ -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; +} diff --git a/assets/oauth2.css/simplehead.css b/assets/oauth2.css/simplehead.css new file mode 100644 index 00000000..639c1da7 --- /dev/null +++ b/assets/oauth2.css/simplehead.css @@ -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; +} diff --git a/assets/oauth2.css/userhead.css b/assets/oauth2.css/userhead.css new file mode 100644 index 00000000..b8e5efe5 --- /dev/null +++ b/assets/oauth2.css/userhead.css @@ -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; +} diff --git a/assets/oauth2.js/app/info.jsx b/assets/oauth2.js/app/info.jsx new file mode 100644 index 00000000..e7962923 --- /dev/null +++ b/assets/oauth2.js/app/info.jsx @@ -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; }, + }; +}; diff --git a/assets/oauth2.js/app/scope.jsx b/assets/oauth2.js/app/scope.jsx new file mode 100644 index 00000000..d9e69b90 --- /dev/null +++ b/assets/oauth2.js/app/scope.jsx @@ -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; }, + }; +}; diff --git a/assets/oauth2.js/authorise.js b/assets/oauth2.js/authorise.js new file mode 100644 index 00000000..7b4f4556 --- /dev/null +++ b/assets/oauth2.js/authorise.js @@ -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.'); + } +}; diff --git a/assets/oauth2.js/csrf.js b/assets/oauth2.js/csrf.js new file mode 100644 index 00000000..7df0610b --- /dev/null +++ b/assets/oauth2.js/csrf.js @@ -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; + }, + }; +})(); diff --git a/assets/oauth2.js/header/header.js b/assets/oauth2.js/header/header.js new file mode 100644 index 00000000..a515f613 --- /dev/null +++ b/assets/oauth2.js/header/header.js @@ -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, + }; +}; diff --git a/assets/oauth2.js/header/user.jsx b/assets/oauth2.js/header/user.jsx new file mode 100644 index 00000000..4ca95630 --- /dev/null +++ b/assets/oauth2.js/header/user.jsx @@ -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; + }, + }; +}; diff --git a/assets/oauth2.js/loading.jsx b/assets/oauth2.js/loading.jsx new file mode 100644 index 00000000..96f0e11a --- /dev/null +++ b/assets/oauth2.js/loading.jsx @@ -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); }, + }; +}; diff --git a/assets/oauth2.js/main.js b/assets/oauth2.js/main.js new file mode 100644 index 00000000..e7238927 --- /dev/null +++ b/assets/oauth2.js/main.js @@ -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(); +})(); diff --git a/assets/oauth2.js/utility.js b/assets/oauth2.js/utility.js new file mode 100644 index 00000000..28bc48cd --- /dev/null +++ b/assets/oauth2.js/utility.js @@ -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; + } +}; diff --git a/assets/oauth2.js/verify.js b/assets/oauth2.js/verify.js new file mode 100644 index 00000000..5c5c09fa --- /dev/null +++ b/assets/oauth2.js/verify.js @@ -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'); + } +}; diff --git a/assets/oauth2.js/xhr.js b/assets/oauth2.js/xhr.js new file mode 100644 index 00000000..c6e4b870 --- /dev/null +++ b/assets/oauth2.js/xhr.js @@ -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), + }; +})(); diff --git a/build.js b/build.js index 8965136a..35fe521b 100644 --- a/build.js +++ b/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', }, diff --git a/config/config.example.cfg b/config/config.example.cfg index 21422f97..e581553b 100644 --- a/config/config.example.cfg +++ b/config/config.example.cfg @@ -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 diff --git a/database/2025_02_01_181944_create_apps_tables.php b/database/2025_02_01_181944_create_apps_tables.php new file mode 100644 index 00000000..c70baa0d --- /dev/null +++ b/database/2025_02_01_181944_create_apps_tables.php @@ -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); + } +} diff --git a/database/2025_02_01_182753_create_scopes_tables.php b/database/2025_02_01_182753_create_scopes_tables.php new file mode 100644 index 00000000..5440c822 --- /dev/null +++ b/database/2025_02_01_182753_create_scopes_tables.php @@ -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); + } +} diff --git a/database/2025_02_01_183150_create_oauth_tables.php b/database/2025_02_01_183150_create_oauth_tables.php new file mode 100644 index 00000000..0a212a59 --- /dev/null +++ b/database/2025_02_01_183150_create_oauth_tables.php @@ -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); + } +} diff --git a/public-legacy/auth/login.php b/public-legacy/auth/login.php index c6928875..35c319a0 100644 --- a/public-legacy/auth/login.php +++ b/public-legacy/auth/login.php @@ -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; } diff --git a/public-legacy/auth/logout.php b/public-legacy/auth/logout.php index fcb7f314..8df0ad2c 100644 --- a/public-legacy/auth/logout.php +++ b/public-legacy/auth/logout.php @@ -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; diff --git a/public-legacy/auth/password.php b/public-legacy/auth/password.php index 6f32afe2..23b9bae7 100644 --- a/public-legacy/auth/password.php +++ b/public-legacy/auth/password.php @@ -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; } diff --git a/public-legacy/auth/register.php b/public-legacy/auth/register.php index 618479b6..b273db39 100644 --- a/public-legacy/auth/register.php +++ b/public-legacy/auth/register.php @@ -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; } diff --git a/public-legacy/auth/twofactor.php b/public-legacy/auth/twofactor.php index c27298b7..b571b399 100644 --- a/public-legacy/auth/twofactor.php +++ b/public-legacy/auth/twofactor.php @@ -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; } diff --git a/public-legacy/comments.php b/public-legacy/comments.php index 7d0dbfed..323c2ae7 100644 --- a/public-legacy/comments.php +++ b/public-legacy/comments.php @@ -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)) diff --git a/public-legacy/forum/posting.php b/public-legacy/forum/posting.php index 55528df0..410806a0 100644 --- a/public-legacy/forum/posting.php +++ b/public-legacy/forum/posting.php @@ -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; diff --git a/public-legacy/manage/users/user.php b/public-legacy/manage/users/user.php index 2d46328d..4d13272d 100644 --- a/public-legacy/manage/users/user.php +++ b/public-legacy/manage/users/user.php @@ -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; diff --git a/public-legacy/members.php b/public-legacy/members.php index 8d84f0da..ab541612 100644 --- a/public-legacy/members.php +++ b/public-legacy/members.php @@ -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 diff --git a/public-legacy/search.php b/public-legacy/search.php index 79fface7..85174264 100644 --- a/public-legacy/search.php +++ b/public-legacy/search.php @@ -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'] : ''; diff --git a/public-legacy/settings/account.php b/public-legacy/settings/account.php index 65886690..6bde1e12 100644 --- a/public-legacy/settings/account.php +++ b/public-legacy/settings/account.php @@ -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 = []; diff --git a/public-legacy/settings/data.php b/public-legacy/settings/data.php index 78e4cfa6..98e248cb 100644 --- a/public-legacy/settings/data.php +++ b/public-legacy/settings/data.php @@ -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; diff --git a/public-legacy/settings/sessions.php b/public-legacy/settings/sessions.php index cd722bc9..b1e65883 100644 --- a/public-legacy/settings/sessions.php +++ b/public-legacy/settings/sessions.php @@ -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 = []; diff --git a/public/images/circle-check-regular.svg b/public/images/circle-check-regular.svg new file mode 100644 index 00000000..0558987c --- /dev/null +++ b/public/images/circle-check-regular.svg @@ -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> \ No newline at end of file diff --git a/public/images/circle-check-solid.svg b/public/images/circle-check-solid.svg new file mode 100644 index 00000000..a5613987 --- /dev/null +++ b/public/images/circle-check-solid.svg @@ -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> \ No newline at end of file diff --git a/public/images/circle-exclamation-solid.svg b/public/images/circle-exclamation-solid.svg new file mode 100644 index 00000000..99a68d73 --- /dev/null +++ b/public/images/circle-exclamation-solid.svg @@ -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> \ No newline at end of file diff --git a/public/images/circle-question-solid.svg b/public/images/circle-question-solid.svg new file mode 100644 index 00000000..3cc83fd0 --- /dev/null +++ b/public/images/circle-question-solid.svg @@ -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> \ No newline at end of file diff --git a/public/images/circle-regular.svg b/public/images/circle-regular.svg new file mode 100644 index 00000000..bd6be279 --- /dev/null +++ b/public/images/circle-regular.svg @@ -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> \ No newline at end of file diff --git a/public/images/circle-xmark-solid.svg b/public/images/circle-xmark-solid.svg new file mode 100644 index 00000000..083c430a --- /dev/null +++ b/public/images/circle-xmark-solid.svg @@ -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> \ No newline at end of file diff --git a/public/images/ellipsis-solid.svg b/public/images/ellipsis-solid.svg new file mode 100644 index 00000000..7283ce39 --- /dev/null +++ b/public/images/ellipsis-solid.svg @@ -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> \ No newline at end of file diff --git a/public/images/exclamation-solid.svg b/public/images/exclamation-solid.svg new file mode 100644 index 00000000..53d7d5b8 --- /dev/null +++ b/public/images/exclamation-solid.svg @@ -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> \ No newline at end of file diff --git a/public/images/flashii.svg b/public/images/flashii.svg new file mode 100644 index 00000000..24f95030 --- /dev/null +++ b/public/images/flashii.svg @@ -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> \ No newline at end of file diff --git a/public/images/globe-solid.svg b/public/images/globe-solid.svg new file mode 100644 index 00000000..c2af6c6a --- /dev/null +++ b/public/images/globe-solid.svg @@ -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> \ No newline at end of file diff --git a/public/images/mobile-screen-solid.svg b/public/images/mobile-screen-solid.svg new file mode 100644 index 00000000..94f8033e --- /dev/null +++ b/public/images/mobile-screen-solid.svg @@ -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> \ No newline at end of file diff --git a/public/images/user-lock-solid.svg b/public/images/user-lock-solid.svg new file mode 100644 index 00000000..74f93a17 --- /dev/null +++ b/public/images/user-lock-solid.svg @@ -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> \ No newline at end of file diff --git a/public/index.php b/public/index.php index bc29cb01..120453ed 100644 --- a/public/index.php +++ b/public/index.php @@ -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. diff --git a/src/ATProto/XrpcClient.php b/src/ATProto/XrpcClient.php index 1e9f20f6..594b8272 100644 --- a/src/ATProto/XrpcClient.php +++ b/src/ATProto/XrpcClient.php @@ -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 = [], diff --git a/src/Apps/AppInfo.php b/src/Apps/AppInfo.php new file mode 100644 index 00000000..6907d5de --- /dev/null +++ b/src/Apps/AppInfo.php @@ -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); + } +} diff --git a/src/Apps/AppScopesInfo.php b/src/Apps/AppScopesInfo.php new file mode 100644 index 00000000..dec81923 --- /dev/null +++ b/src/Apps/AppScopesInfo.php @@ -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; + } +} diff --git a/src/Apps/AppType.php b/src/Apps/AppType.php new file mode 100644 index 00000000..145a2f28 --- /dev/null +++ b/src/Apps/AppType.php @@ -0,0 +1,8 @@ +<?php +namespace Misuzu\Apps; + +enum AppType: string { + case Public = 'public'; + case Confidential = 'confidential'; + case Trusted = 'trusted'; +} diff --git a/src/Apps/AppUriInfo.php b/src/Apps/AppUriInfo.php new file mode 100644 index 00000000..8d5bb22d --- /dev/null +++ b/src/Apps/AppUriInfo.php @@ -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); + } +} diff --git a/src/Apps/AppsContext.php b/src/Apps/AppsContext.php new file mode 100644 index 00000000..813942fb --- /dev/null +++ b/src/Apps/AppsContext.php @@ -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; + } +} diff --git a/src/Apps/AppsData.php b/src/Apps/AppsData.php new file mode 100644 index 00000000..c4e307dc --- /dev/null +++ b/src/Apps/AppsData.php @@ -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); + } +} diff --git a/src/Apps/ScopeInfo.php b/src/Apps/ScopeInfo.php new file mode 100644 index 00000000..dffea02c --- /dev/null +++ b/src/Apps/ScopeInfo.php @@ -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); + } +} diff --git a/src/Apps/ScopeInfoGetField.php b/src/Apps/ScopeInfoGetField.php new file mode 100644 index 00000000..f6063a10 --- /dev/null +++ b/src/Apps/ScopeInfoGetField.php @@ -0,0 +1,7 @@ +<?php +namespace Misuzu\Apps; + +enum ScopeInfoGetField { + case Id; + case String; +} diff --git a/src/Apps/ScopesData.php b/src/Apps/ScopesData.php new file mode 100644 index 00000000..d03b961a --- /dev/null +++ b/src/Apps/ScopesData.php @@ -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); + } +} diff --git a/src/AuditLog/AuditLogData.php b/src/AuditLog/AuditLogData.php index 175fca76..59ae72a4 100644 --- a/src/AuditLog/AuditLogData.php +++ b/src/AuditLog/AuditLogData.php @@ -89,7 +89,7 @@ class AuditLogData { $stmt->execute(); - return $stmt->getResult()->getIterator(AuditLogInfo::fromResult(...)); + return $stmt->getResultIterator(AuditLogInfo::fromResult(...)); } /** @param mixed[] $params */ diff --git a/src/Auth/AuthInfo.php b/src/Auth/AuthInfo.php index f8f9771b..8c5a84ed 100644 --- a/src/Auth/AuthInfo.php +++ b/src/Auth/AuthInfo.php @@ -39,7 +39,7 @@ class AuthInfo { $this->setInfo(AuthTokenInfo::empty()); } - public bool $isLoggedIn { + public bool $loggedIn { get => $this->userInfo !== null; } diff --git a/src/Auth/LoginAttemptsData.php b/src/Auth/LoginAttemptsData.php index 1a5e07b6..ce7a00f2 100644 --- a/src/Auth/LoginAttemptsData.php +++ b/src/Auth/LoginAttemptsData.php @@ -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( diff --git a/src/Auth/SessionsData.php b/src/Auth/SessionsData.php index c0312cae..e3590b41 100644 --- a/src/Auth/SessionsData.php +++ b/src/Auth/SessionsData.php @@ -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( diff --git a/src/Changelog/ChangelogData.php b/src/Changelog/ChangelogData.php index eec20a12..603a2ab2 100644 --- a/src/Changelog/ChangelogData.php +++ b/src/Changelog/ChangelogData.php @@ -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 { diff --git a/src/Comments/CommentsData.php b/src/Comments/CommentsData.php index ae38ab32..a98fb6a6 100644 --- a/src/Comments/CommentsData.php +++ b/src/Comments/CommentsData.php @@ -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( diff --git a/src/Comments/CommentsEx.php b/src/Comments/CommentsEx.php index 2515b320..4701744a 100644 --- a/src/Comments/CommentsEx.php +++ b/src/Comments/CommentsEx.php @@ -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([ diff --git a/src/Counters/CountersData.php b/src/Counters/CountersData.php index ff37ae9a..eb9c3818 100644 --- a/src/Counters/CountersData.php +++ b/src/Counters/CountersData.php @@ -43,7 +43,7 @@ class CountersData { $pagination->addToStatement($stmt); $stmt->execute(); - return $stmt->getResult()->getIterator(CounterInfo::fromResult(...)); + return $stmt->getResultIterator(CounterInfo::fromResult(...)); } /** diff --git a/src/Emoticons/EmotesData.php b/src/Emoticons/EmotesData.php index 4e216494..7fa84ca2 100644 --- a/src/Emoticons/EmotesData.php +++ b/src/Emoticons/EmotesData.php @@ -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 { diff --git a/src/Forum/ForumCategoriesData.php b/src/Forum/ForumCategoriesData.php index 9dc0bb3c..5a50218f 100644 --- a/src/Forum/ForumCategoriesData.php +++ b/src/Forum/ForumCategoriesData.php @@ -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); diff --git a/src/Forum/ForumCategoriesRoutes.php b/src/Forum/ForumCategoriesRoutes.php index daa25996..d83c80db 100644 --- a/src/Forum/ForumCategoriesRoutes.php +++ b/src/Forum/ForumCategoriesRoutes.php @@ -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'))) diff --git a/src/Forum/ForumPostsData.php b/src/Forum/ForumPostsData.php index 32551d0e..af453f54 100644 --- a/src/Forum/ForumPostsData.php +++ b/src/Forum/ForumPostsData.php @@ -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 */ diff --git a/src/Forum/ForumPostsRoutes.php b/src/Forum/ForumPostsRoutes.php index e472b883..8a64eb6b 100644 --- a/src/Forum/ForumPostsRoutes.php +++ b/src/Forum/ForumPostsRoutes.php @@ -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'))) diff --git a/src/Forum/ForumTopicRedirectsData.php b/src/Forum/ForumTopicRedirectsData.php index a3fd6255..f9e28394 100644 --- a/src/Forum/ForumTopicRedirectsData.php +++ b/src/Forum/ForumTopicRedirectsData.php @@ -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 { diff --git a/src/Forum/ForumTopicsData.php b/src/Forum/ForumTopicsData.php index 270fa2a6..9c7cfee1 100644 --- a/src/Forum/ForumTopicsData.php +++ b/src/Forum/ForumTopicsData.php @@ -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( diff --git a/src/Forum/ForumTopicsRoutes.php b/src/Forum/ForumTopicsRoutes.php index 9f597a5e..d24b6e18 100644 --- a/src/Forum/ForumTopicsRoutes.php +++ b/src/Forum/ForumTopicsRoutes.php @@ -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'))) diff --git a/src/Home/HomeRoutes.php b/src/Home/HomeRoutes.php index bf8536e0..e8cbc99c 100644 --- a/src/Home/HomeRoutes.php +++ b/src/Home/HomeRoutes.php @@ -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); } diff --git a/src/Messages/MessagesData.php b/src/Messages/MessagesData.php index b308d766..e218b669 100644 --- a/src/Messages/MessagesData.php +++ b/src/Messages/MessagesData.php @@ -151,7 +151,7 @@ class MessagesData { $stmt->execute(); - return $stmt->getResult()->getIterator(MessageInfo::fromResult(...)); + return $stmt->getResultIterator(MessageInfo::fromResult(...)); } public function getMessageInfo( diff --git a/src/Messages/MessagesRoutes.php b/src/Messages/MessagesRoutes.php index 33c11d9a..b2b794f1 100644 --- a/src/Messages/MessagesRoutes.php +++ b/src/Messages/MessagesRoutes.php @@ -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 diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php index 89e31794..20c0e08b 100644 --- a/src/MisuzuContext.php +++ b/src/MisuzuContext.php @@ -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( diff --git a/src/News/NewsData.php b/src/News/NewsData.php index e78d4a8f..65e27df7 100644 --- a/src/News/NewsData.php +++ b/src/News/NewsData.php @@ -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 { diff --git a/src/OAuth2/OAuth2AccessInfo.php b/src/OAuth2/OAuth2AccessInfo.php new file mode 100644 index 00000000..83a1c661 --- /dev/null +++ b/src/OAuth2/OAuth2AccessInfo.php @@ -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()); + } +} diff --git a/src/OAuth2/OAuth2AccessInfoGetField.php b/src/OAuth2/OAuth2AccessInfoGetField.php new file mode 100644 index 00000000..d9568046 --- /dev/null +++ b/src/OAuth2/OAuth2AccessInfoGetField.php @@ -0,0 +1,7 @@ +<?php +namespace Misuzu\OAuth2; + +enum OAuth2AccessInfoGetField { + case Id; + case Token; +} diff --git a/src/OAuth2/OAuth2ApiRoutes.php b/src/OAuth2/OAuth2ApiRoutes.php new file mode 100644 index 00000000..15a42654 --- /dev/null +++ b/src/OAuth2/OAuth2ApiRoutes.php @@ -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.', + ]); + } +} diff --git a/src/OAuth2/OAuth2AuthorisationData.php b/src/OAuth2/OAuth2AuthorisationData.php new file mode 100644 index 00000000..eac0fb74 --- /dev/null +++ b/src/OAuth2/OAuth2AuthorisationData.php @@ -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'); + } +} diff --git a/src/OAuth2/OAuth2AuthorisationInfo.php b/src/OAuth2/OAuth2AuthorisationInfo.php new file mode 100644 index 00000000..4ebcd203 --- /dev/null +++ b/src/OAuth2/OAuth2AuthorisationInfo.php @@ -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()); + } +} diff --git a/src/OAuth2/OAuth2Context.php b/src/OAuth2/OAuth2Context.php new file mode 100644 index 00000000..84ed7e96 --- /dev/null +++ b/src/OAuth2/OAuth2Context.php @@ -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); + } +} diff --git a/src/OAuth2/OAuth2DeviceApproval.php b/src/OAuth2/OAuth2DeviceApproval.php new file mode 100644 index 00000000..1aab7a33 --- /dev/null +++ b/src/OAuth2/OAuth2DeviceApproval.php @@ -0,0 +1,8 @@ +<?php +namespace Misuzu\OAuth2; + +enum OAuth2DeviceApproval: string { + case Pending = 'pending'; + case Approved = 'approved'; + case Denied = 'denied'; +} diff --git a/src/OAuth2/OAuth2DeviceInfo.php b/src/OAuth2/OAuth2DeviceInfo.php new file mode 100644 index 00000000..a40860da --- /dev/null +++ b/src/OAuth2/OAuth2DeviceInfo.php @@ -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()); + } +} diff --git a/src/OAuth2/OAuth2DevicesData.php b/src/OAuth2/OAuth2DevicesData.php new file mode 100644 index 00000000..05d049c2 --- /dev/null +++ b/src/OAuth2/OAuth2DevicesData.php @@ -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'); + } +} diff --git a/src/OAuth2/OAuth2RefreshInfo.php b/src/OAuth2/OAuth2RefreshInfo.php new file mode 100644 index 00000000..2afe0bda --- /dev/null +++ b/src/OAuth2/OAuth2RefreshInfo.php @@ -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()); + } +} diff --git a/src/OAuth2/OAuth2RefreshInfoGetField.php b/src/OAuth2/OAuth2RefreshInfoGetField.php new file mode 100644 index 00000000..82b343a7 --- /dev/null +++ b/src/OAuth2/OAuth2RefreshInfoGetField.php @@ -0,0 +1,8 @@ +<?php +namespace Misuzu\OAuth2; + +enum OAuth2RefreshInfoGetField { + case Id; + case Access; + case Token; +} diff --git a/src/OAuth2/OAuth2RpcHandler.php b/src/OAuth2/OAuth2RpcHandler.php new file mode 100644 index 00000000..de32466c --- /dev/null +++ b/src/OAuth2/OAuth2RpcHandler.php @@ -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); + } +} diff --git a/src/OAuth2/OAuth2TokensData.php b/src/OAuth2/OAuth2TokensData.php new file mode 100644 index 00000000..cd7f778c --- /dev/null +++ b/src/OAuth2/OAuth2TokensData.php @@ -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()'); + } +} diff --git a/src/OAuth2/OAuth2WebRoutes.php b/src/OAuth2/OAuth2WebRoutes.php new file mode 100644 index 00000000..87710250 --- /dev/null +++ b/src/OAuth2/OAuth2WebRoutes.php @@ -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; + } +} diff --git a/src/Profile/ProfileFieldsData.php b/src/Profile/ProfileFieldsData.php index 6b07a168..25fed64e 100644 --- a/src/Profile/ProfileFieldsData.php +++ b/src/Profile/ProfileFieldsData.php @@ -43,7 +43,7 @@ class ProfileFieldsData { $stmt->execute(); - return $stmt->getResult()->getIterator(ProfileFieldInfo::fromResult(...)); + return $stmt->getResultIterator(ProfileFieldInfo::fromResult(...)); } public function getField(string $fieldId): ProfileFieldInfo { @@ -116,7 +116,7 @@ class ProfileFieldsData { $stmt->execute(); - return $stmt->getResult()->getIterator(ProfileFieldFormatInfo::fromResult(...)); + return $stmt->getResultIterator(ProfileFieldFormatInfo::fromResult(...)); } public function getFieldFormat(string $formatId): ProfileFieldFormatInfo { @@ -161,7 +161,7 @@ class ProfileFieldsData { $stmt->nextParameter($userInfo); $stmt->execute(); - return $stmt->getResult()->getIterator(ProfileFieldValueInfo::fromResult(...)); + return $stmt->getResultIterator(ProfileFieldValueInfo::fromResult(...)); } public function getFieldValue( diff --git a/src/Redirects/IncrementalRedirectsData.php b/src/Redirects/IncrementalRedirectsData.php index 95c93539..7ebe2b9e 100644 --- a/src/Redirects/IncrementalRedirectsData.php +++ b/src/Redirects/IncrementalRedirectsData.php @@ -21,7 +21,7 @@ class IncrementalRedirectsData { $stmt = $this->cache->get($query); $stmt->execute(); - return $stmt->getResult()->getIterator(IncrementalRedirectInfo::fromResult(...)); + return $stmt->getResultIterator(IncrementalRedirectInfo::fromResult(...)); } public const int INC_BY_ID = 1; diff --git a/src/Redirects/NamedRedirectsData.php b/src/Redirects/NamedRedirectsData.php index 9ad37790..bf5a5ee0 100644 --- a/src/Redirects/NamedRedirectsData.php +++ b/src/Redirects/NamedRedirectsData.php @@ -21,7 +21,7 @@ class NamedRedirectsData { $stmt = $this->cache->get($query); $stmt->execute(); - return $stmt->getResult()->getIterator(NamedRedirectInfo::fromResult(...)); + return $stmt->getResultIterator(NamedRedirectInfo::fromResult(...)); } public const int NAMED_BY_ID = 1; diff --git a/src/SharpChat/SharpChatRoutes.php b/src/SharpChat/SharpChatRoutes.php index 3e54a6a3..60762e88 100644 --- a/src/SharpChat/SharpChatRoutes.php +++ b/src/SharpChat/SharpChatRoutes.php @@ -66,7 +66,7 @@ final class SharpChatRoutes implements RouteHandler { #[HttpGet('/_sockchat/login')] public function getLogin(HttpResponseBuilder $response, HttpRequest $request): void { - if(!$this->authInfo->isLoggedIn) { + if(!$this->authInfo->loggedIn) { $response->redirect($this->urls->format('auth-login')); return; } diff --git a/src/TemplatingExtension.php b/src/TemplatingExtension.php index 99eb101e..19e1c297 100644 --- a/src/TemplatingExtension.php +++ b/src/TemplatingExtension.php @@ -81,7 +81,7 @@ final class TemplatingExtension extends AbstractExtension { 'menu' => [], ]; - if($this->ctx->authInfo->isLoggedIn) + if($this->ctx->authInfo->loggedIn) $home['menu'][] = [ 'title' => 'Members', 'url' => $this->ctx->urls->format('user-list'), @@ -146,7 +146,7 @@ final class TemplatingExtension extends AbstractExtension { public function getUserMenu(bool $inBroomCloset, string $manageUrl = ''): array { $menu = []; - if($this->ctx->authInfo->isLoggedIn) { + if($this->ctx->authInfo->loggedIn) { $userInfo = $this->ctx->authInfo->userInfo; $globalPerms = $this->ctx->authInfo->getPerms('global'); @@ -214,7 +214,7 @@ final class TemplatingExtension extends AbstractExtension { /** @return array<string, array<string, string>> */ public function getManageMenu(): array { $globalPerms = $this->ctx->authInfo->getPerms('global'); - if(!$this->ctx->authInfo->isLoggedIn || !$globalPerms->check(Perm::G_IS_JANITOR)) + if(!$this->ctx->authInfo->loggedIn || !$globalPerms->check(Perm::G_IS_JANITOR)) return []; $menu = [ diff --git a/src/Users/BansData.php b/src/Users/BansData.php index 8307631d..4a8f7e97 100644 --- a/src/Users/BansData.php +++ b/src/Users/BansData.php @@ -94,7 +94,7 @@ class BansData { $pagination->addToStatement($stmt); $stmt->execute(); - return $stmt->getResult()->getIterator(BanInfo::fromResult(...)); + return $stmt->getResultIterator(BanInfo::fromResult(...)); } public function getBan(string $banId): BanInfo { diff --git a/src/Users/ModNotesData.php b/src/Users/ModNotesData.php index 968bc05d..e0acce06 100644 --- a/src/Users/ModNotesData.php +++ b/src/Users/ModNotesData.php @@ -86,7 +86,7 @@ class ModNotesData { $pagination->addToStatement($stmt); $stmt->execute(); - return $stmt->getResult()->getIterator(ModNoteInfo::fromResult(...)); + return $stmt->getResultIterator(ModNoteInfo::fromResult(...)); } public function getNote(string $noteId): ModNoteInfo { diff --git a/src/Users/RolesData.php b/src/Users/RolesData.php index 36d1ae0e..d559aff4 100644 --- a/src/Users/RolesData.php +++ b/src/Users/RolesData.php @@ -86,7 +86,7 @@ class RolesData { $pagination->addToStatement($stmt); $stmt->execute(); - return $stmt->getResult()->getIterator(RoleInfo::fromResult(...)); + return $stmt->getResultIterator(RoleInfo::fromResult(...)); } public function getRole(string $roleId): RoleInfo { diff --git a/src/Users/UsersData.php b/src/Users/UsersData.php index bc0c3a27..0a1de565 100644 --- a/src/Users/UsersData.php +++ b/src/Users/UsersData.php @@ -206,7 +206,7 @@ class UsersData { $pagination->addToStatement($stmt); $stmt->execute(); - return $stmt->getResult()->getIterator(UserInfo::fromResult(...)); + return $stmt->getResultIterator(UserInfo::fromResult(...)); } public const GET_USER_ID = 0x01; diff --git a/src/Users/WarningsData.php b/src/Users/WarningsData.php index e36f94a1..f5ea77ad 100644 --- a/src/Users/WarningsData.php +++ b/src/Users/WarningsData.php @@ -102,7 +102,7 @@ class WarningsData { $pagination->addToStatement($stmt); $stmt->execute(); - return $stmt->getResult()->getIterator(WarningInfo::fromResult(...)); + return $stmt->getResultIterator(WarningInfo::fromResult(...)); } public function getWarning(string $warnId): WarningInfo { diff --git a/templates/_layout/header.twig b/templates/_layout/header.twig index e5117c15..87549d97 100644 --- a/templates/_layout/header.twig +++ b/templates/_layout/header.twig @@ -56,7 +56,7 @@ </a> {% endfor %} - {% if globals.auth_info.isLoggedIn %} + {% if globals.auth_info.loggedIn %} {% set user_info = globals.auth_info.userInfo %} <a href="{{ url('user-profile', {'user': user_info.id}) }}" class="avatar header__desktop__user__avatar" title="{{ user_info.name }}"> {{ avatar(user_info.id, 60, user_info.name) }} @@ -80,7 +80,7 @@ </a> <label class="header__mobile__icon header__mobile__avatar" for="toggle-mobile-header"> - {% if globals.auth_info.isLoggedIn %} + {% if globals.auth_info.loggedIn %} {% set user_info = globals.auth_info.userInfo %} {{ avatar(user_info.id, 40, user_info.name) }} {% else %} diff --git a/templates/oauth2/authorise.twig b/templates/oauth2/authorise.twig new file mode 100644 index 00000000..a1391f19 --- /dev/null +++ b/templates/oauth2/authorise.twig @@ -0,0 +1,29 @@ +{% extends 'oauth2/master.twig' %} + +{% set body_header_icon = 'wait' %} +{% set body_header_text = 'Loading...' %} +{% set body_title = 'Authorisation Request' %} + +{% block body_content %} + <div class="js-loading"></div> + + <div class="js-authorise-error hidden"> + <div class="oauth2-errorbody"> + <p class="js-authorise-error-text"></p> + </div> + </div> + + <form class="js-authorise-form hidden"> + <div class="oauth2-authorise-requesting"> + <p>A third-party application is requesting permission to access your account.</p> + </div> + + <div class="js-authorise-form-info"></div> + <div class="js-authorise-form-scope"></div> + + <div class="oauth2-authorise-buttons"> + <button name="approve" value="yes" class="oauth2-authorise-button oauth2-authorise-button-accept">Authorise</button> + <button name="approve" value="no" class="oauth2-authorise-button oauth2-authorise-button-deny">Cancel</button> + </div> + </form> +{% endblock %} diff --git a/templates/oauth2/login.twig b/templates/oauth2/login.twig new file mode 100644 index 00000000..2faa8f15 --- /dev/null +++ b/templates/oauth2/login.twig @@ -0,0 +1,62 @@ +{% extends 'oauth2/master.twig' %} + +{% set body_header_icon = 'login' %} +{% set body_header_text = 'Not logged in' %} +{% set body_title = 'Authorisation Request' %} + +{% block body_content %} + <div class="oauth2-authorise-requesting"> + {% if app is defined and not app.isTrusted %} + <p>A third-party application is requesting permission to access your account.</p> + {% endif %} + + <p>You must be logged in to authorise applications. <a href="{{ login_url }}" target="_blank">Log in</a> or <a href="{{ register_url }}" target="_blank">create an account</a> and reload this page to try again.</p> + + {% if app is defined and app.isTrusted %} + <p>You will be redirected to the following application after logging in.</p> + {% endif %} + </div> + + {% if app is defined %} + <div class="oauth2-appinfo"> + <div class="oauth2-appinfo-name"> + {{ app.name }} + </div>{# TODO: author should be listed #} + <div class="oauth2-appinfo-links"> + <a href="{{ app.website }}" target="_blank" rel="noopener noreferrer" class="oauth2-appinfo-link" title="Website"> + <div class="oauth2-appinfo-link-icon oauth2-appinfo-link-icon-globe"></div> + <div class="oauth2-appinfo-link-text">{{ app.websiteDisplay }}</div> + </a> + </div> + <div class="oauth2-appinfo-summary"> + <p>{{ app.summary }}</p> + </div> + </div> + + <div class="oauth2-scope"> + <div class="oauth2-scope-header">This application will be able to:</div> + <div class="oauth2-scope-perms"> + <div class="oauth2-scope-perm"> + <div class="oauth2-scope-perm-icon"></div> + <div class="oauth2-scope-perm-text">Do anything because I have not made up scopes yet.</div> + </div> + <div class="oauth2-scope-perm"> + <div class="oauth2-scope-perm-icon"></div> + <div class="oauth2-scope-perm-text">Eat soup.</div> + </div> + <div class="oauth2-scope-perm"> + <div class="oauth2-scope-perm-icon"></div> + <div class="oauth2-scope-perm-text">These are placeholders.</div> + </div> + <div class="oauth2-scope-perm"> + <div class="oauth2-scope-perm-icon"></div> + <div class="oauth2-scope-perm-text">This one is really long because I want to test wrapping and how the chevron icon thing will handle it so there will be a lot of text here, the app will not be gaining anything from it but yeah sometimes you just need to explode seventy times.</div> + </div> + </div> + </div> + {% else %} + <div class="oauth2-authorise-device"> + <p>More details about the application will be displayed once you're logged in.</p> + </div> + {% endif %} +{% endblock %} diff --git a/templates/oauth2/master.twig b/templates/oauth2/master.twig new file mode 100644 index 00000000..cecb0cde --- /dev/null +++ b/templates/oauth2/master.twig @@ -0,0 +1,39 @@ +{% extends 'html.twig' %} + +{% set html_title = (title is defined ? (title ~ ' :: ') : '') ~ globals.site_info.name %} + +{% block html_head %} + <link href="{{ asset('oauth2.css') }}" rel="stylesheet"> +{% endblock %} + +{% block html_body %} + <div class="oauth2-wrapper"> + <div class="oauth2-dialog"> + <div class="oauth2-dialog-body"> + {% block body %} + {% block body_header %} + <header class="oauth2-header js-oauth2-header"> + {% set body_header_icon = body_header_icon|default('') %} + <div class="oauth2-simplehead js-oauth2-header-simple{% if body_header_icon == '' %} hidden{% endif %}"> + <div class="oauth2-simplehead-icon oauth2-simplehead-icon--{{ body_header_icon }} js-oauth2-header-simple-icon"></div> + <div class="oauth2-simplehead-text js-oauth2-header-simple-text"> + {{ body_header_text|default('') }} + </div> + </div> + </header> + {% endblock %} + + <div class="oauth2-body"> + <div class="oauth2-banner"> + <div class="oauth2-banner-text">{{ body_title|default('') }}</div> + <div class="oauth2-banner-logo">{{ globals.site_info.name }}</div> + </div> + + {% block body_content %}{% endblock %} + </div> + {% endblock %} + </div> + </div> + </div> + <script src="{{ asset('oauth2.js') }}"></script> +{% endblock %} diff --git a/templates/oauth2/verify.twig b/templates/oauth2/verify.twig new file mode 100644 index 00000000..906a2837 --- /dev/null +++ b/templates/oauth2/verify.twig @@ -0,0 +1,58 @@ +{% extends 'oauth2/master.twig' %} + +{% set body_header_icon = 'code' %} +{% set body_header_text = 'Code authorisation' %} +{% set body_title = 'Authorisation Request' %} + +{% block body_content %} + <div class="js-loading"></div> + + <form class="js-verify-code hidden"> + <div class="oauth2-authorise-requesting"> + <p>A third-party application that cannot display websites on its own is requesting permission to access your account.</p> + <p>Enter the code displayed on the device or application in the box below to continue authorisation.</p> + </div> + + <div class="oauth2-device-form"> + <input type="text" class="oauth2-device-code js-device-code" name="code" placeholder="XXX-XXX-XXX" autofocus> + </div> + + <div class="oauth2-authorise-buttons"> + <button class="oauth2-authorise-button">Next</button> + </div> + </form> + + <form class="js-verify-authorise hidden"> + <div class="oauth2-authorise-requesting"> + <p>A third-party application is requesting permission to access your account.</p> + </div> + + <div class="js-verify-authorise-info"></div> + <div class="js-verify-authorise-scope"></div> + + <div class="oauth2-authorise-buttons"> + <button name="approve" value="yes" class="oauth2-authorise-button oauth2-authorise-button-accept">Authorise</button> + <button name="approve" value="no" class="oauth2-authorise-button oauth2-authorise-button-deny">Cancel</button> + </div> + </form> + + <div class="js-verify-approved hidden"> + <div class="oauth2-approval"> + <div class="oauth2-approval-icon oauth2-approval-icon-approved"></div> + <div class="oauth2-approval-header">Approved!</div> + <div class="oauth2-approval-text"> + <p>You have approved the authorisation request. You should now be signed in on the target device or application.</p> + </div> + </div> + </div> + + <div class="js-verify-denied hidden"> + <div class="oauth2-approval"> + <div class="oauth2-approval-icon oauth2-approval-icon-denied"></div> + <div class="oauth2-approval-header">Denied!</div> + <div class="oauth2-approval-text"> + <p>You have denied the authorisation request. Please return to the target device or application and follow displayed instructions.</p> + </div> + </div> + </div> +{% endblock %}