From ffb0cb96df8caab8c48fab6a3c36f9e57ef73e88 Mon Sep 17 00:00:00 2001 From: flashwave Date: Tue, 30 Jul 2024 00:16:42 +0000 Subject: [PATCH] Implemented partial UI for device authorisation. --- assets/hanyuu.js/csrfp.js | 44 +++++ assets/hanyuu.js/html.js | 116 ++++++++++++++ assets/hanyuu.js/main.js | 3 + assets/hanyuu.js/xhr.js | 114 +++++++++++++ assets/oauth2.css/approval.css | 35 ++++ assets/oauth2.css/authorise.css | 15 ++ assets/oauth2.css/device.css | 43 +++++ assets/oauth2.css/loading.css | 36 +++++ assets/oauth2.css/main.css | 3 + assets/oauth2.js/app/info.jsx | 30 ++++ assets/oauth2.js/app/scope.jsx | 29 ++++ assets/oauth2.js/device/verify.js | 150 ++++++++++++++++++ assets/oauth2.js/header/header.js | 55 +++++++ assets/oauth2.js/header/user.jsx | 53 +++++++ assets/oauth2.js/loading.jsx | 84 ++++++++++ assets/oauth2.js/main.js | 5 + build.js | 1 + ...201438_nine_character_device_user_code.php | 9 ++ ...0_001441_removed_device_attempts_field.php | 9 ++ package-lock.json | 118 +++++++------- public/images/circle-check-solid.svg | 1 + public/images/circle-xmark-solid.svg | 1 + public/images/mobile-screen-solid.svg | 1 + src/HanyuuContext.php | 2 +- src/OAuth2/OAuth2DeviceInfo.php | 16 +- src/OAuth2/OAuth2DevicesData.php | 17 +- src/OAuth2/OAuth2ErrorRoutes.php | 0 src/OAuth2/OAuth2Routes.php | 131 +++++++++++++-- templates/oauth2/authorise.twig | 100 ++++++------ templates/oauth2/device/verify.twig | 58 +++++++ templates/oauth2/error.twig | 14 +- templates/oauth2/login.twig | 47 +++--- templates/oauth2/master.twig | 26 ++- 33 files changed, 1181 insertions(+), 185 deletions(-) create mode 100644 assets/hanyuu.js/csrfp.js create mode 100644 assets/hanyuu.js/html.js create mode 100644 assets/hanyuu.js/main.js create mode 100644 assets/hanyuu.js/xhr.js create mode 100644 assets/oauth2.css/approval.css create mode 100644 assets/oauth2.css/device.css create mode 100644 assets/oauth2.css/loading.css create mode 100644 assets/oauth2.js/app/info.jsx create mode 100644 assets/oauth2.js/app/scope.jsx create mode 100644 assets/oauth2.js/device/verify.js create mode 100644 assets/oauth2.js/header/header.js create mode 100644 assets/oauth2.js/header/user.jsx create mode 100644 assets/oauth2.js/loading.jsx create mode 100644 database/2024_07_27_201438_nine_character_device_user_code.php create mode 100644 database/2024_07_30_001441_removed_device_attempts_field.php create mode 100644 public/images/circle-check-solid.svg create mode 100644 public/images/circle-xmark-solid.svg create mode 100644 public/images/mobile-screen-solid.svg create mode 100644 src/OAuth2/OAuth2ErrorRoutes.php create mode 100644 templates/oauth2/device/verify.twig diff --git a/assets/hanyuu.js/csrfp.js b/assets/hanyuu.js/csrfp.js new file mode 100644 index 0000000..effb880 --- /dev/null +++ b/assets/hanyuu.js/csrfp.js @@ -0,0 +1,44 @@ +const HanyuuCSRFP = (() => { + let elem; + const getElement = () => { + if(elem === undefined) + elem = document.querySelector('meta[name="csrfp-token"]'); + return elem; + }; + + const getToken = () => { + const elem = getElement(); + return typeof elem.content === 'string' ? elem.content : ''; + }; + + const setToken = token => { + if(typeof token !== 'string') + throw 'token must be a string'; + + let elem = getElement(); + if(typeof elem.content === 'string') + elem.content = token; + else { + elem = document.createElement('meta'); + elem.name = 'csrfp-token'; + elem.content = token; + document.head.appendChild(elem); + } + }; + + return { + getToken: getToken, + setToken: setToken, + setFromHeaders: result => { + if(typeof result.headers !== 'function') + throw 'result.headers is not a function'; + + const headers = result.headers(); + if(!(headers instanceof Map)) + throw 'result of result.headers does not return a map'; + + if(headers.has('x-csrfp-token')) + setToken(headers.get('x-csrfp-token')); + }, + }; +})(); diff --git a/assets/hanyuu.js/html.js b/assets/hanyuu.js/html.js new file mode 100644 index 0000000..286d560 --- /dev/null +++ b/assets/hanyuu.js/html.js @@ -0,0 +1,116 @@ +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 }); diff --git a/assets/hanyuu.js/main.js b/assets/hanyuu.js/main.js new file mode 100644 index 0000000..f9a3deb --- /dev/null +++ b/assets/hanyuu.js/main.js @@ -0,0 +1,3 @@ +#include csrfp.js +#include html.js +#include xhr.js diff --git a/assets/hanyuu.js/xhr.js b/assets/hanyuu.js/xhr.js new file mode 100644 index 0000000..c6759f7 --- /dev/null +++ b/assets/hanyuu.js/xhr.js @@ -0,0 +1,114 @@ +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'; + + 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(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) => { + let responseHeaders = undefined; + + xhr.onload = ev => resolve({ + status: xhr.status, + body: () => xhr.response, + text: () => xhr.responseText, + headers: () => { + if(responseHeaders !== undefined) + return responseHeaders; + + responseHeaders = new Map; + + const raw = xhr.getAllResponseHeaders().trim().split(/[\r\n]+/); + for(const name in raw) + if(raw.hasOwnProperty(name)) { + const parts = raw[name].split(': '); + responseHeaders.set(parts.shift(), parts.join(': ')); + } + + return responseHeaders; + }, + xhr: xhr, + ev: ev, + }); + + xhr.onabort = ev => reject({ + abort: true, + xhr: xhr, + ev: ev, + }); + + xhr.onerror = ev => reject({ + abort: false, + 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/assets/oauth2.css/approval.css b/assets/oauth2.css/approval.css new file mode 100644 index 0000000..598a10b --- /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 index 533b71a..b04a03d 100644 --- a/assets/oauth2.css/authorise.css +++ b/assets/oauth2.css/authorise.css @@ -7,6 +7,14 @@ 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; @@ -30,6 +38,13 @@ 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] { diff --git a/assets/oauth2.css/device.css b/assets/oauth2.css/device.css new file mode 100644 index 0000000..2e653fa --- /dev/null +++ b/assets/oauth2.css/device.css @@ -0,0 +1,43 @@ +.oauth2-devicehead { + display: flex; + align-items: center; +} + +.oauth2-devicehead-icon { + flex: 0 0 auto; + background-color: #fff; + mask: url('/images/mobile-screen-solid.svg') no-repeat center; + width: 40px; + height: 40px; + margin: 10px; +} + +.oauth2-devicehead-text { + font-size: 1.8em; + line-height: 1.4em; +} + +.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/loading.css b/assets/oauth2.css/loading.css new file mode 100644 index 0000000..925d947 --- /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 index 928a75b..690ecbd 100644 --- a/assets/oauth2.css/main.css +++ b/assets/oauth2.css/main.css @@ -35,10 +35,13 @@ margin: 10px; } +@include loading.css; @include banner.css; @include error.css; @include login.css; +@include device.css; @include userhead.css; @include appinfo.css; @include scope.css; @include authorise.css; +@include approval.css; diff --git a/assets/oauth2.js/app/info.jsx b/assets/oauth2.js/app/info.jsx new file mode 100644 index 0000000..43bf5e3 --- /dev/null +++ b/assets/oauth2.js/app/info.jsx @@ -0,0 +1,30 @@ +const HanyuuOAuth2AppInfoLink = function(info) { + const element = + + + ; + + return { + get element() { return element; }, + }; +}; + +const HanyuuOAuth2AppInfo = function(info) { + const linksElem =