Clean up OAuth2 provider code, support multiple request types in preparation for OIDC

This commit is contained in:
Evert Prants 2020-06-05 18:18:23 +03:00
parent 2a7aec1b46
commit 51fd91e3db
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
12 changed files with 235 additions and 120 deletions

View File

@ -2,151 +2,152 @@ import error from '../error'
import response from '../response'
import model from '../model'
import authorization from './code'
import wrap from '../../../../scripts/asyncRoute'
const usermodel = model.user
import wrap from '../wrap'
module.exports = wrap(async (req, res, next) => {
let clientId = null
let redirectUri = null
let responseType = null
let grantType = null
let grantTypes = []
let scope = null
let user = null
if (!req.query.redirect_uri) {
return response.error(req, res, new error.InvalidRequest('redirect_uri field is mandatory for authorization endpoint'), redirectUri)
throw new error.InvalidRequest('redirect_uri field is mandatory for authorization endpoint')
}
redirectUri = req.query.redirect_uri
console.debug('Parameter redirect uri is', redirectUri)
if (!req.query.client_id) {
return response.error(req, res, new error.InvalidRequest('client_id field is mandatory for authorization endpoint'), redirectUri)
throw new error.InvalidRequest('client_id field is mandatory for authorization endpoint')
}
// Check for client_secret (prevent passing it)
if (req.query.client_secret) {
return response.error(req, res, new error.InvalidRequest('client_secret field should not be passed to the authorization endpoint'), redirectUri)
throw new error.InvalidRequest('client_secret field should not be passed to the authorization endpoint')
}
clientId = req.query.client_id
console.debug('Parameter client_id is', clientId)
if (!req.query.response_type) {
return response.error(req, res, new error.InvalidRequest('response_type field is mandatory for authorization endpoint'), redirectUri)
throw new error.InvalidRequest('response_type field is mandatory for authorization endpoint')
}
responseType = req.query.response_type
console.debug('Parameter response_type is', responseType)
switch (responseType) {
case 'code':
grantType = 'authorization_code'
break
case 'token':
grantType = 'implicit'
break
default:
return response.error(req, res, new error.UnsupportedResponseType('Unknown response_type parameter passed'), redirectUri)
// Support multiple types
const responseTypes = responseType.split(' ')
for (const i in responseTypes) {
switch (responseTypes[i]) {
case 'code':
grantTypes.push('authorization_code')
break
case 'token':
grantTypes.push('implicit')
break
// case 'id_token':
case 'none':
grantTypes.push(responseTypes[i])
break
default:
throw new error.UnsupportedResponseType('Unknown response_type parameter passed')
}
}
console.debug('Parameter response_type is', responseType)
// Filter out duplicates
grantTypes = grantTypes.filter(function (value, index, self) {
return self.indexOf(value) === index
})
// "None" type cannot be combined with others
if (grantTypes.length > 1 && grantTypes.indexOf('none') !== -1) {
throw new error.InvalidRequest('Grant type "none" cannot be combined with other grant types')
}
console.debug('Parameter grant_type is', grantTypes.join(' '))
const client = await req.oauth2.model.client.fetchById(clientId)
if (!client) {
return response.error(req, res, new error.InvalidClient('Client not found'), redirectUri)
throw new error.InvalidClient('Client not found')
}
// TODO: multiple redirect URI
if (!req.oauth2.model.client.getRedirectUri(client)) {
return response.error(req, res, new error.UnsupportedResponseType('The client has not set a redirect uri'), redirectUri)
throw new error.UnsupportedResponseType('The client has not set a redirect uri')
} else if (!req.oauth2.model.client.checkRedirectUri(client, redirectUri)) {
return response.error(req, res, new error.InvalidRequest('Wrong RedirectUri provided'), redirectUri)
} else {
console.debug('redirect_uri check passed')
throw new error.InvalidRequest('Wrong RedirectUri provided')
}
console.debug('redirect_uri check passed')
if (!req.oauth2.model.client.checkGrantType(client, grantType)) {
return response.error(req, res, new error.UnauthorizedClient('This client does not support this grant type'), redirectUri)
} else {
console.debug('Grant type check passed')
// The client needs to support all grant types
for (const i in grantTypes) {
if (!req.oauth2.model.client.checkGrantType(client, grantTypes[i]) && grantTypes[i] !== 'none') {
throw new error.UnauthorizedClient('This client does not support grant type ' + grantTypes[i])
}
}
console.debug('Grant type check passed')
scope = req.oauth2.model.client.transformScope(req.query.scope)
scope = req.oauth2.model.client.checkScope(client, scope)
if (!scope) {
return response.error(req, res, new error.InvalidScope('Client does not allow access to this scope'), redirectUri)
} else {
console.debug('Scope check passed')
throw new error.InvalidScope('Client does not allow access to this scope')
}
console.debug('Scope check passed')
user = await req.oauth2.model.user.fetchFromRequest(req)
if (!user) {
return response.error(req, res, new error.InvalidRequest('There is no currently logged in user'), redirectUri)
throw new error.InvalidRequest('There is no currently logged in user')
} else {
if (!user.username) {
return response.error(req, res, new error.Forbidden(user), redirectUri)
throw new error.Forbidden(user)
}
console.debug('User fetched from request')
}
let data = null
let resObj = {}
let consented = false
if (req.method === 'GET') {
let hasAuthorizedAlready = await usermodel.clientAllowed(user.id, client.id, scope)
if (client.verified === 1) {
hasAuthorizedAlready = true
consented = true
} else {
consented = await model.user.consented(user.id, client.id, scope)
}
}
if (hasAuthorizedAlready) {
if (grantType === 'authorization_code') {
try {
data = await authorization.Code(req, res, client, scope, user, redirectUri, false)
} catch (err) {
return response.error(req, res, err, redirectUri)
}
// Ask for consent
if (!consented) return req.oauth2.decision(req, res, client, scope, user, redirectUri)
return response.data(req, res, { code: data }, redirectUri)
} else if (grantType === 'implicit') {
try {
data = await authorization.Implicit(req, res, client, scope, user, redirectUri, false)
} catch (err) {
return response.error(req, res, err, redirectUri)
}
for (const i in grantTypes) {
let data = null
switch (grantTypes[i]) {
case 'authorization_code':
data = await authorization.Code(req, res, client, scope, user, redirectUri, !consented)
return response.data(req, res, {
resObj = Object.assign({ code: data }, resObj)
break
case 'implicit':
data = await authorization.Implicit(req, res, client, scope, user, redirectUri, !consented)
resObj = Object.assign({
token_type: 'bearer',
access_token: data,
expires_in: req.oauth2.model.accessToken.ttl
}, redirectUri)
}
} else {
return req.oauth2.decision(req, res, client, scope, user, redirectUri)
}
}, resObj)
return response.error(req, res, new error.InvalidRequest('Invalid request method'), redirectUri)
break
case 'none':
resObj = {}
break
default:
throw new error.UnsupportedResponseType('Unknown response_type parameter passed')
}
}
if (grantType === 'authorization_code') {
try {
data = await authorization.Code(req, res, client, scope, user, redirectUri, true)
} catch (err) {
return response.error(req, res, err, redirectUri)
}
return response.data(req, res, { code: data }, redirectUri)
} else if (grantType === 'implicit') {
try {
data = await authorization.Implicit(req, res, client, scope, user, redirectUri, true)
} catch (err) {
return response.error(req, res, err, redirectUri)
}
return response.data(req, res, {
token_type: 'bearer',
access_token: data,
expires_in: req.oauth2.model.accessToken.ttl
}, redirectUri)
} else {
return response.error(req, res, new error.InvalidRequest('Invalid request method'), redirectUri)
}
})
// Return non-code response types as fragment instead of query
return response.data(req, res, resObj, redirectUri, responseType !== 'code')
}, true)

View File

@ -1,23 +1,22 @@
import error from '../../error'
import model from '../../model'
module.exports = async (req, res, client, scope, user, redirectUri, createAllowFuture) => {
module.exports = async (req, res, client, scope, user, redirectUri, consentRequested) => {
let codeValue = null
if (req.method === 'POST' && req.session.csrf && !(req.body.csrf && req.body.csrf === req.session.csrf)) {
throw new error.InvalidRequest('Invalid session')
}
if (createAllowFuture) {
if (consentRequested) {
if (!req.body || (typeof req.body.decision) === 'undefined') {
throw new error.InvalidRequest('No decision parameter passed')
} else if (req.body.decision === '0') {
throw new error.AccessDenied('User denied access to the resource')
} else {
console.debug('Decision check passed')
}
console.debug('Decision check passed')
await model.user.allowClient(user.id, client.id, scope)
await model.user.consent(user.id, client.id, scope)
}
try {

View File

@ -1,23 +1,22 @@
import error from '../../error'
import model from '../../model'
module.exports = async (req, res, client, scope, user, redirectUri, createAllowFuture) => {
module.exports = async (req, res, client, scope, user, redirectUri, consentRequested) => {
let accessTokenValue = null
if (req.method === 'POST' && req.session.csrf && !(req.body.csrf && req.body.csrf === req.session.csrf)) {
throw new error.InvalidRequest('Invalid session')
}
if (createAllowFuture) {
if (consentRequested) {
if (!req.body || (typeof req.body.decision) === 'undefined') {
throw new error.InvalidRequest('No decision parameter passed')
} else if (req.body.decision === '0') {
throw new error.AccessDenied('User denied access to the resource')
} else {
console.debug('Decision check passed')
}
console.debug('Decision check passed')
await model.user.allowClient(user.id, client.id, scope)
await model.user.consent(user.id, client.id, scope)
}
try {

View File

@ -1,6 +1,6 @@
import error from '../error'
import response from '../response'
import wrap from '../../../../scripts/asyncRoute'
import wrap from '../wrap'
module.exports = wrap(async function (req, res) {
let clientId = null
@ -12,21 +12,21 @@ module.exports = wrap(async function (req, res) {
console.debug('Client credentials parsed from body parameters ', clientId, clientSecret)
} else {
if (!req.headers || !req.headers.authorization) {
return response.error(req, res, new error.InvalidRequest('No authorization header passed'))
throw new error.InvalidRequest('No authorization header passed')
}
let pieces = req.headers.authorization.split(' ', 2)
if (!pieces || pieces.length !== 2) {
return response.error(req, res, new error.InvalidRequest('Authorization header is corrupted'))
throw new error.InvalidRequest('Authorization header is corrupted')
}
if (pieces[0] !== 'Basic') {
return response.error(req, res, new error.InvalidRequest('Unsupported authorization method:', pieces[0]))
throw new error.InvalidRequest('Unsupported authorization method:', pieces[0])
}
pieces = Buffer.from(pieces[1], 'base64').toString('ascii').split(':', 2)
if (!pieces || pieces.length !== 2) {
return response.error(req, res, new error.InvalidRequest('Authorization header has corrupted data'))
throw new error.InvalidRequest('Authorization header has corrupted data')
}
clientId = pieces[0]
@ -35,12 +35,12 @@ module.exports = wrap(async function (req, res) {
}
if (!req.body.token) {
return response.error(req, res, new error.InvalidRequest('Token not provided in request body'))
throw new error.InvalidRequest('Token not provided in request body')
}
const token = await req.oauth2.model.accessToken.fetchByToken(req.body.token)
if (!token) {
return response.error(req, res, new error.InvalidRequest('Token does not exist'))
throw new error.InvalidRequest('Token does not exist')
}
const ttl = req.oauth2.model.accessToken.getTTL(token)

View File

@ -1,7 +1,7 @@
import token from './tokens'
import error from '../error'
import response from '../response'
import wrap from '../../../../scripts/asyncRoute'
import wrap from '../wrap'
module.exports = wrap(async (req, res) => {
let clientId = null
@ -14,21 +14,21 @@ module.exports = wrap(async (req, res) => {
console.debug('Client credentials parsed from body parameters', clientId, clientSecret)
} else {
if (!req.headers || !req.headers.authorization) {
return response.error(req, res, new error.InvalidRequest('No authorization header passed'))
throw new error.InvalidRequest('No authorization header passed')
}
let pieces = req.headers.authorization.split(' ', 2)
if (!pieces || pieces.length !== 2) {
return response.error(req, res, new error.InvalidRequest('Authorization header is corrupted'))
throw new error.InvalidRequest('Authorization header is corrupted')
}
if (pieces[0] !== 'Basic') {
return response.error(req, res, new error.InvalidRequest('Unsupported authorization method:', pieces[0]))
throw new error.InvalidRequest('Unsupported authorization method:', pieces[0])
}
pieces = Buffer.from(pieces[1], 'base64').toString('ascii').split(':', 2)
if (!pieces || pieces.length !== 2) {
return response.error(req, res, new error.InvalidRequest('Authorization header has corrupted data'))
throw new error.InvalidRequest('Authorization header has corrupted data')
}
clientId = pieces[0]
@ -37,7 +37,7 @@ module.exports = wrap(async (req, res) => {
}
if (!req.body.grant_type) {
return response.error(req, res, new error.InvalidRequest('Request body does not contain grant_type parameter'))
throw new error.InvalidRequest('Request body does not contain grant_type parameter')
}
grantType = req.body.grant_type
@ -46,16 +46,16 @@ module.exports = wrap(async (req, res) => {
const client = await req.oauth2.model.client.fetchById(clientId)
if (!client) {
return response.error(req, res, new error.InvalidClient('Client not found'))
throw new error.InvalidClient('Client not found')
}
const valid = req.oauth2.model.client.checkSecret(client, clientSecret)
if (!valid) {
return response.error(req, res, new error.UnauthorizedClient('Invalid client secret'))
throw new error.UnauthorizedClient('Invalid client secret')
}
if (!req.oauth2.model.client.checkGrantType(client, grantType) && grantType !== 'refresh_token') {
return response.error(req, res, new error.UnauthorizedClient('Invalid grant type for the client'))
throw new error.UnauthorizedClient('Invalid grant type for the client')
} else {
console.debug('Grant type check passed')
}

View File

@ -12,10 +12,10 @@ module.exports = async (oauth2, client, wantScope) => {
if (!scope) {
throw new error.InvalidScope('Client does not allow access to this scope')
} else {
console.debug('Scope check passed ', scope)
}
console.debug('Scope check passed ', scope)
try {
resObj.access_token = await oauth2.model.accessToken.create(null, oauth2.model.client.getId(client), scope, oauth2.model.accessToken.ttl)
} catch (err) {

View File

@ -1,6 +1,5 @@
import response from './response'
import error from './error'
import wrap from '../../../scripts/asyncRoute'
import wrap from './wrap'
const middleware = wrap(async function (req, res, next) {
console.debug('Parsing bearer token')
@ -12,12 +11,12 @@ const middleware = wrap(async function (req, res, next) {
// Check authorization header
if (!pieces || pieces.length !== 2) {
return response.error(req, res, new error.AccessDenied('Wrong authorization header'))
throw new error.AccessDenied('Wrong authorization header')
}
// Only bearer auth is supported
if (pieces[0].toLowerCase() !== 'bearer') {
return response.error(req, res, new error.AccessDenied('Unsupported authorization method in header'))
throw new error.AccessDenied('Unsupported authorization method in header')
}
token = pieces[1]
@ -29,15 +28,15 @@ const middleware = wrap(async function (req, res, next) {
token = req.body.access_token
console.debug('Bearer token parsed from body params:', token)
} else {
return response.error(req, res, new error.AccessDenied('Bearer token not found'))
throw new error.AccessDenied('Bearer token not found')
}
// Try to fetch access token
const object = await req.oauth2.model.accessToken.fetchByToken(token)
if (!object) {
response.error(req, res, new error.Forbidden('Token not found or has expired'))
throw new error.Forbidden('Token not found or has expired')
} else if (!req.oauth2.model.accessToken.checkTTL(object)) {
response.error(req, res, new error.Forbidden('Token is expired'))
throw new error.Forbidden('Token is expired')
} else {
req.oauth2.accessToken = object
console.debug('AccessToken fetched', object)

View File

@ -4,6 +4,11 @@ import Users from '../index'
import crypto from 'crypto'
const OAuthDB = {
scopes: {
email: ['See your Email address', 'View the user\'s email address'],
image: ['', 'View the user\'s profile picture'],
privilege: ['', 'See the user\'s privilege level']
},
accessToken: {
ttl: config.oauth2.access_token_life,
getToken: (object) => {
@ -237,7 +242,7 @@ const OAuthDB = {
return req.session.user
},
clientAllowed: async (userId, clientId, scope) => {
consented: async (userId, clientId, scope) => {
if (typeof scope === 'object') {
scope = scope.join(' ')
}
@ -265,7 +270,7 @@ const OAuthDB = {
return correct
},
allowClient: async (userId, clientId, scope) => {
consent: async (userId, clientId, scope) => {
if (!config.oauth2.save_decision) return true
if (typeof scope === 'object') {
scope = scope.join(' ')

View File

@ -40,9 +40,9 @@ module.exports.error = function (req, res, err, redirectUri) {
}
}
module.exports.data = function (req, res, obj, redirectUri, anchor) {
module.exports.data = function (req, res, obj, redirectUri, fragment) {
if (redirectUri) {
if (anchor) {
if (fragment) {
redirectUri += '#'
} else {
redirectUri += (redirectUri.indexOf('?') === -1 ? '?' : '&')

View File

@ -0,0 +1,9 @@
import response from './response'
module.exports = function (fn, redir) {
return function (req, res, next) {
fn(req, res, next).catch(e => {
return response.error(req, res, e, redir ? req.query.redirect_uri : null)
})
}
}

View File

@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
width="29.898235mm"
height="29.898235mm"
viewBox="0 0 29.898235 29.898235"
version="1.1"
id="svg8">
<defs
id="defs2">
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath877">
<rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#6fefff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect879"
width="24.856066"
height="24.856066"
x="2.5211489"
y="2.5227857"
transform="rotate(2.4309033e-4)" />
</clipPath>
</defs>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer1"
transform="translate(-1.2029979e-7,-0.00176382)">
<rect
transform="rotate(-3.5293181)"
y="3.4147503"
x="1.5723678"
height="24.856066"
width="24.856066"
id="rect1083"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#00a1b5;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;font-variant-east_asian:normal" />
<rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#00e1fd;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;font-variant-east_asian:normal"
id="rect815"
width="24.856066"
height="24.856066"
x="0.13787289"
y="4.5749965"
transform="rotate(-8.5307657)" />
<rect
transform="rotate(2.4309033e-4)"
y="2.5227857"
x="2.5211489"
height="24.856066"
width="24.856066"
id="rect817"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#6fefff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
<g
transform="matrix(0.06114614,0,0,0.06114614,-0.73486692,-0.70252952)"
id="g4612"
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#03a9f4;fill-opacity:1;stroke:none;stroke-width:4.88991;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1">
<path
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#03a9f4;fill-opacity:1;stroke:none;stroke-width:4.88991;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1"
d="M 235,89 V 423 H 152 V 115 l 26,-26 z"
id="path4495" />
<circle
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#03a9f4;fill-opacity:1;stroke:none;stroke-width:4.88991;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1"
id="path4497"
cy="115"
cx="178"
r="26" />
<circle
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#03a9f4;fill-opacity:1;stroke:none;stroke-width:4.88991;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1"
id="path4497-0"
cy="230"
cx="335"
r="26" />
<path
style="font-variation-settings:normal;vector-effect:none;fill:#03a9f4;fill-opacity:1;stroke:none;stroke-width:4.88991;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1"
d="M 277,256 V 89 l 84,3e-6 L 361.00002,230 335,256 Z"
id="path4516" />
<circle
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#03a9f4;fill-opacity:1;stroke:none;stroke-width:4.88991;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1"
id="path4497-0-6"
cy="397"
cx="335"
r="26" />
<path
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#03a9f4;fill-opacity:1;stroke:none;stroke-width:4.88991;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1"
d="m 277,423 v -83 h 84 l 2e-5,57 L 335,423 Z"
id="path4516-5" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -54,7 +54,7 @@ block body
i.fas.fa-fw.fa-lock
|See your Password
li
i.fas.fa-fw.fa-gears
i.fas.fa-fw.fa-cogs
|Change your Account Settings
.alert.alert-info
b Note!