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 response from '../response'
import model from '../model' import model from '../model'
import authorization from './code' import authorization from './code'
import wrap from '../../../../scripts/asyncRoute' import wrap from '../wrap'
const usermodel = model.user
module.exports = wrap(async (req, res, next) => { module.exports = wrap(async (req, res, next) => {
let clientId = null let clientId = null
let redirectUri = null let redirectUri = null
let responseType = null let responseType = null
let grantType = null let grantTypes = []
let scope = null let scope = null
let user = null let user = null
if (!req.query.redirect_uri) { 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 redirectUri = req.query.redirect_uri
console.debug('Parameter redirect uri is', redirectUri) console.debug('Parameter redirect uri is', redirectUri)
if (!req.query.client_id) { 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) // Check for client_secret (prevent passing it)
if (req.query.client_secret) { 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 clientId = req.query.client_id
console.debug('Parameter client_id is', clientId) console.debug('Parameter client_id is', clientId)
if (!req.query.response_type) { 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 responseType = req.query.response_type
console.debug('Parameter response_type is', responseType) console.debug('Parameter response_type is', responseType)
switch (responseType) { // Support multiple types
case 'code': const responseTypes = responseType.split(' ')
grantType = 'authorization_code' for (const i in responseTypes) {
break switch (responseTypes[i]) {
case 'token': case 'code':
grantType = 'implicit' grantTypes.push('authorization_code')
break break
default: case 'token':
return response.error(req, res, new error.UnsupportedResponseType('Unknown response_type parameter passed'), redirectUri) 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) const client = await req.oauth2.model.client.fetchById(clientId)
if (!client) { 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)) { 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)) { } else if (!req.oauth2.model.client.checkRedirectUri(client, redirectUri)) {
return response.error(req, res, new error.InvalidRequest('Wrong RedirectUri provided'), redirectUri) throw new error.InvalidRequest('Wrong RedirectUri provided')
} else {
console.debug('redirect_uri check passed')
} }
console.debug('redirect_uri check passed')
if (!req.oauth2.model.client.checkGrantType(client, grantType)) { // The client needs to support all grant types
return response.error(req, res, new error.UnauthorizedClient('This client does not support this grant type'), redirectUri) for (const i in grantTypes) {
} else { if (!req.oauth2.model.client.checkGrantType(client, grantTypes[i]) && grantTypes[i] !== 'none') {
console.debug('Grant type check passed') 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.transformScope(req.query.scope)
scope = req.oauth2.model.client.checkScope(client, scope) scope = req.oauth2.model.client.checkScope(client, scope)
if (!scope) { if (!scope) {
return response.error(req, res, new error.InvalidScope('Client does not allow access to this scope'), redirectUri) throw new error.InvalidScope('Client does not allow access to this scope')
} else {
console.debug('Scope check passed')
} }
console.debug('Scope check passed')
user = await req.oauth2.model.user.fetchFromRequest(req) user = await req.oauth2.model.user.fetchFromRequest(req)
if (!user) { 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 { } else {
if (!user.username) { if (!user.username) {
return response.error(req, res, new error.Forbidden(user), redirectUri) throw new error.Forbidden(user)
} }
console.debug('User fetched from request') console.debug('User fetched from request')
} }
let data = null let resObj = {}
let consented = false
if (req.method === 'GET') { if (req.method === 'GET') {
let hasAuthorizedAlready = await usermodel.clientAllowed(user.id, client.id, scope)
if (client.verified === 1) { if (client.verified === 1) {
hasAuthorizedAlready = true consented = true
} else {
consented = await model.user.consented(user.id, client.id, scope)
} }
}
if (hasAuthorizedAlready) { // Ask for consent
if (grantType === 'authorization_code') { if (!consented) return req.oauth2.decision(req, res, client, scope, user, redirectUri)
try {
data = await authorization.Code(req, res, client, scope, user, redirectUri, false)
} catch (err) {
return response.error(req, res, err, redirectUri)
}
return response.data(req, res, { code: data }, redirectUri) for (const i in grantTypes) {
} else if (grantType === 'implicit') { let data = null
try { switch (grantTypes[i]) {
data = await authorization.Implicit(req, res, client, scope, user, redirectUri, false) case 'authorization_code':
} catch (err) { data = await authorization.Code(req, res, client, scope, user, redirectUri, !consented)
return response.error(req, res, err, redirectUri)
}
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', token_type: 'bearer',
access_token: data, access_token: data,
expires_in: req.oauth2.model.accessToken.ttl expires_in: req.oauth2.model.accessToken.ttl
}, redirectUri) }, resObj)
}
} else {
return req.oauth2.decision(req, res, client, scope, user, redirectUri)
}
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') { // Return non-code response types as fragment instead of query
try { return response.data(req, res, resObj, redirectUri, responseType !== 'code')
data = await authorization.Code(req, res, client, scope, user, redirectUri, true) }, 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)
}
})

View File

@ -1,23 +1,22 @@
import error from '../../error' import error from '../../error'
import model from '../../model' 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 let codeValue = null
if (req.method === 'POST' && req.session.csrf && !(req.body.csrf && req.body.csrf === req.session.csrf)) { if (req.method === 'POST' && req.session.csrf && !(req.body.csrf && req.body.csrf === req.session.csrf)) {
throw new error.InvalidRequest('Invalid session') throw new error.InvalidRequest('Invalid session')
} }
if (createAllowFuture) { if (consentRequested) {
if (!req.body || (typeof req.body.decision) === 'undefined') { if (!req.body || (typeof req.body.decision) === 'undefined') {
throw new error.InvalidRequest('No decision parameter passed') throw new error.InvalidRequest('No decision parameter passed')
} else if (req.body.decision === '0') { } else if (req.body.decision === '0') {
throw new error.AccessDenied('User denied access to the resource') 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 { try {

View File

@ -1,23 +1,22 @@
import error from '../../error' import error from '../../error'
import model from '../../model' 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 let accessTokenValue = null
if (req.method === 'POST' && req.session.csrf && !(req.body.csrf && req.body.csrf === req.session.csrf)) { if (req.method === 'POST' && req.session.csrf && !(req.body.csrf && req.body.csrf === req.session.csrf)) {
throw new error.InvalidRequest('Invalid session') throw new error.InvalidRequest('Invalid session')
} }
if (createAllowFuture) { if (consentRequested) {
if (!req.body || (typeof req.body.decision) === 'undefined') { if (!req.body || (typeof req.body.decision) === 'undefined') {
throw new error.InvalidRequest('No decision parameter passed') throw new error.InvalidRequest('No decision parameter passed')
} else if (req.body.decision === '0') { } else if (req.body.decision === '0') {
throw new error.AccessDenied('User denied access to the resource') 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 { try {

View File

@ -1,6 +1,6 @@
import error from '../error' import error from '../error'
import response from '../response' import response from '../response'
import wrap from '../../../../scripts/asyncRoute' import wrap from '../wrap'
module.exports = wrap(async function (req, res) { module.exports = wrap(async function (req, res) {
let clientId = null let clientId = null
@ -12,21 +12,21 @@ module.exports = wrap(async function (req, res) {
console.debug('Client credentials parsed from body parameters ', clientId, clientSecret) console.debug('Client credentials parsed from body parameters ', clientId, clientSecret)
} else { } else {
if (!req.headers || !req.headers.authorization) { 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) let pieces = req.headers.authorization.split(' ', 2)
if (!pieces || pieces.length !== 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') { 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) pieces = Buffer.from(pieces[1], 'base64').toString('ascii').split(':', 2)
if (!pieces || pieces.length !== 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] clientId = pieces[0]
@ -35,12 +35,12 @@ module.exports = wrap(async function (req, res) {
} }
if (!req.body.token) { 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) const token = await req.oauth2.model.accessToken.fetchByToken(req.body.token)
if (!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) const ttl = req.oauth2.model.accessToken.getTTL(token)

View File

@ -1,7 +1,7 @@
import token from './tokens' import token from './tokens'
import error from '../error' import error from '../error'
import response from '../response' import response from '../response'
import wrap from '../../../../scripts/asyncRoute' import wrap from '../wrap'
module.exports = wrap(async (req, res) => { module.exports = wrap(async (req, res) => {
let clientId = null let clientId = null
@ -14,21 +14,21 @@ module.exports = wrap(async (req, res) => {
console.debug('Client credentials parsed from body parameters', clientId, clientSecret) console.debug('Client credentials parsed from body parameters', clientId, clientSecret)
} else { } else {
if (!req.headers || !req.headers.authorization) { 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) let pieces = req.headers.authorization.split(' ', 2)
if (!pieces || pieces.length !== 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') { 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) pieces = Buffer.from(pieces[1], 'base64').toString('ascii').split(':', 2)
if (!pieces || pieces.length !== 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] clientId = pieces[0]
@ -37,7 +37,7 @@ module.exports = wrap(async (req, res) => {
} }
if (!req.body.grant_type) { 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 grantType = req.body.grant_type
@ -46,16 +46,16 @@ module.exports = wrap(async (req, res) => {
const client = await req.oauth2.model.client.fetchById(clientId) const client = await req.oauth2.model.client.fetchById(clientId)
if (!client) { 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) const valid = req.oauth2.model.client.checkSecret(client, clientSecret)
if (!valid) { 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') { 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 { } else {
console.debug('Grant type check passed') console.debug('Grant type check passed')
} }

View File

@ -12,10 +12,10 @@ module.exports = async (oauth2, client, wantScope) => {
if (!scope) { if (!scope) {
throw new error.InvalidScope('Client does not allow access to this 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 { try {
resObj.access_token = await oauth2.model.accessToken.create(null, oauth2.model.client.getId(client), scope, oauth2.model.accessToken.ttl) resObj.access_token = await oauth2.model.accessToken.create(null, oauth2.model.client.getId(client), scope, oauth2.model.accessToken.ttl)
} catch (err) { } catch (err) {

View File

@ -1,6 +1,5 @@
import response from './response'
import error from './error' import error from './error'
import wrap from '../../../scripts/asyncRoute' import wrap from './wrap'
const middleware = wrap(async function (req, res, next) { const middleware = wrap(async function (req, res, next) {
console.debug('Parsing bearer token') console.debug('Parsing bearer token')
@ -12,12 +11,12 @@ const middleware = wrap(async function (req, res, next) {
// Check authorization header // Check authorization header
if (!pieces || pieces.length !== 2) { 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 // Only bearer auth is supported
if (pieces[0].toLowerCase() !== 'bearer') { 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] token = pieces[1]
@ -29,15 +28,15 @@ const middleware = wrap(async function (req, res, next) {
token = req.body.access_token token = req.body.access_token
console.debug('Bearer token parsed from body params:', token) console.debug('Bearer token parsed from body params:', token)
} else { } 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 // Try to fetch access token
const object = await req.oauth2.model.accessToken.fetchByToken(token) const object = await req.oauth2.model.accessToken.fetchByToken(token)
if (!object) { 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)) { } 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 { } else {
req.oauth2.accessToken = object req.oauth2.accessToken = object
console.debug('AccessToken fetched', object) console.debug('AccessToken fetched', object)

View File

@ -4,6 +4,11 @@ import Users from '../index'
import crypto from 'crypto' import crypto from 'crypto'
const OAuthDB = { 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: { accessToken: {
ttl: config.oauth2.access_token_life, ttl: config.oauth2.access_token_life,
getToken: (object) => { getToken: (object) => {
@ -237,7 +242,7 @@ const OAuthDB = {
return req.session.user return req.session.user
}, },
clientAllowed: async (userId, clientId, scope) => { consented: async (userId, clientId, scope) => {
if (typeof scope === 'object') { if (typeof scope === 'object') {
scope = scope.join(' ') scope = scope.join(' ')
} }
@ -265,7 +270,7 @@ const OAuthDB = {
return correct return correct
}, },
allowClient: async (userId, clientId, scope) => { consent: async (userId, clientId, scope) => {
if (!config.oauth2.save_decision) return true if (!config.oauth2.save_decision) return true
if (typeof scope === 'object') { if (typeof scope === 'object') {
scope = scope.join(' ') 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 (redirectUri) {
if (anchor) { if (fragment) {
redirectUri += '#' redirectUri += '#'
} else { } else {
redirectUri += (redirectUri.indexOf('?') === -1 ? '?' : '&') 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 i.fas.fa-fw.fa-lock
|See your Password |See your Password
li li
i.fas.fa-fw.fa-gears i.fas.fa-fw.fa-cogs
|Change your Account Settings |Change your Account Settings
.alert.alert-info .alert.alert-info
b Note! b Note!