oauth2 revokations

This commit is contained in:
Evert Prants 2022-03-20 16:50:12 +02:00
parent 88f19163c6
commit c35cb362ff
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
36 changed files with 681 additions and 65 deletions

219
package-lock.json generated
View File

@ -16,8 +16,11 @@
"@nestjs/serve-static": "^2.2.2",
"@nestjs/throttler": "^2.0.1",
"bcrypt": "^5.0.1",
"body-parser": "^1.19.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"connect-redis": "^6.1.3",
"cookie-parser": "^1.4.6",
"cropperjs": "^1.5.12",
"dotenv": "^16.0.0",
"express-session": "^1.17.2",
@ -30,6 +33,7 @@
"otplib": "^12.0.1",
"pug": "^3.0.2",
"qrcode": "^1.5.0",
"redis": "^3.1.2",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
@ -44,6 +48,8 @@
"@nestjs/schematics": "^8.0.0",
"@nestjs/testing": "^8.0.0",
"@types/bcrypt": "^5.0.0",
"@types/connect-redis": "^0.0.18",
"@types/csurf": "^1.11.2",
"@types/express": "^4.17.13",
"@types/express-session": "^1.17.4",
"@types/jest": "27.4.1",
@ -3129,12 +3135,33 @@
"@types/node": "*"
}
},
"node_modules/@types/connect-redis": {
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/@types/connect-redis/-/connect-redis-0.0.18.tgz",
"integrity": "sha512-iGygGbXgPIr94DEAuoluWhzre3c2/ew5NPlbW9IWvwCTXMM1YCmc7M9wpXMkYqt6kB9aO1sjZnmDzyugUu+2vQ==",
"dev": true,
"dependencies": {
"@types/express": "*",
"@types/express-session": "*",
"@types/ioredis": "*",
"@types/redis": "^2.8.0"
}
},
"node_modules/@types/cookiejar": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz",
"integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==",
"dev": true
},
"node_modules/@types/csurf": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@types/csurf/-/csurf-1.11.2.tgz",
"integrity": "sha512-9bc98EnwmC1S0aSJiA8rWwXtgXtXHHOQOsGHptImxFgqm6CeH+mIOunHRg6+/eg2tlmDMX3tY7XrWxo2M/nUNQ==",
"dev": true,
"dependencies": {
"@types/express-serve-static-core": "*"
}
},
"node_modules/@types/eslint": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz",
@ -3202,6 +3229,15 @@
"@types/node": "*"
}
},
"node_modules/@types/ioredis": {
"version": "4.28.10",
"resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz",
"integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz",
@ -3326,6 +3362,15 @@
"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==",
"dev": true
},
"node_modules/@types/redis": {
"version": "2.8.32",
"resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz",
"integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.13.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz",
@ -4986,6 +5031,14 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/connect-redis": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-6.1.3.tgz",
"integrity": "sha512-aaNluLlAn/3JPxRwdzw7lhvEoU6Enb+d83xnokUNhC9dktqBoawKWL+WuxinxvBLTz6q9vReTnUDnUslaz74aw==",
"engines": {
"node": ">=12"
}
},
"node_modules/consola": {
"version": "2.15.3",
"resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz",
@ -5047,6 +5100,26 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz",
"integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==",
"dependencies": {
"cookie": "0.4.1",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@ -9982,6 +10055,56 @@
"node": ">= 0.10"
}
},
"node_modules/redis": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz",
"integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==",
"dependencies": {
"denque": "^1.5.0",
"redis-commands": "^1.7.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-redis"
}
},
"node_modules/redis-commands": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz",
"integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ=="
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/redis/node_modules/denque": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz",
"integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==",
"engines": {
"node": ">=0.10"
}
},
"node_modules/reflect-metadata": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
@ -14606,12 +14729,33 @@
"@types/node": "*"
}
},
"@types/connect-redis": {
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/@types/connect-redis/-/connect-redis-0.0.18.tgz",
"integrity": "sha512-iGygGbXgPIr94DEAuoluWhzre3c2/ew5NPlbW9IWvwCTXMM1YCmc7M9wpXMkYqt6kB9aO1sjZnmDzyugUu+2vQ==",
"dev": true,
"requires": {
"@types/express": "*",
"@types/express-session": "*",
"@types/ioredis": "*",
"@types/redis": "^2.8.0"
}
},
"@types/cookiejar": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz",
"integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==",
"dev": true
},
"@types/csurf": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@types/csurf/-/csurf-1.11.2.tgz",
"integrity": "sha512-9bc98EnwmC1S0aSJiA8rWwXtgXtXHHOQOsGHptImxFgqm6CeH+mIOunHRg6+/eg2tlmDMX3tY7XrWxo2M/nUNQ==",
"dev": true,
"requires": {
"@types/express-serve-static-core": "*"
}
},
"@types/eslint": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz",
@ -14679,6 +14823,15 @@
"@types/node": "*"
}
},
"@types/ioredis": {
"version": "4.28.10",
"resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz",
"integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/istanbul-lib-coverage": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz",
@ -14803,6 +14956,15 @@
"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==",
"dev": true
},
"@types/redis": {
"version": "2.8.32",
"resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz",
"integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/serve-static": {
"version": "1.13.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz",
@ -16077,6 +16239,11 @@
}
}
},
"connect-redis": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-6.1.3.tgz",
"integrity": "sha512-aaNluLlAn/3JPxRwdzw7lhvEoU6Enb+d83xnokUNhC9dktqBoawKWL+WuxinxvBLTz6q9vReTnUDnUslaz74aw=="
},
"consola": {
"version": "2.15.3",
"resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz",
@ -16131,6 +16298,22 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz",
"integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA=="
},
"cookie-parser": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz",
"integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==",
"requires": {
"cookie": "0.4.1",
"cookie-signature": "1.0.6"
},
"dependencies": {
"cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA=="
}
}
},
"cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@ -19903,6 +20086,42 @@
"resolve": "^1.1.6"
}
},
"redis": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz",
"integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==",
"requires": {
"denque": "^1.5.0",
"redis-commands": "^1.7.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0"
},
"dependencies": {
"denque": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz",
"integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw=="
}
}
},
"redis-commands": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz",
"integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ=="
},
"redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60="
},
"redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=",
"requires": {
"redis-errors": "^1.0.0"
}
},
"reflect-metadata": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",

View File

@ -30,8 +30,11 @@
"@nestjs/serve-static": "^2.2.2",
"@nestjs/throttler": "^2.0.1",
"bcrypt": "^5.0.1",
"body-parser": "^1.19.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"connect-redis": "^6.1.3",
"cookie-parser": "^1.4.6",
"cropperjs": "^1.5.12",
"dotenv": "^16.0.0",
"express-session": "^1.17.2",
@ -44,6 +47,7 @@
"otplib": "^12.0.1",
"pug": "^3.0.2",
"qrcode": "^1.5.0",
"redis": "^3.1.2",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
@ -58,6 +62,8 @@
"@nestjs/schematics": "^8.0.0",
"@nestjs/testing": "^8.0.0",
"@types/bcrypt": "^5.0.0",
"@types/connect-redis": "^0.0.18",
"@types/csurf": "^1.11.2",
"@types/express": "^4.17.13",
"@types/express-session": "^1.17.4",
"@types/jest": "27.4.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -3,7 +3,7 @@
flex-direction: column;
justify-content: space-between;
align-items: center;
background-color: #005b74;
background-color: var(--main-darker);
margin: 2rem -4rem;
padding: 2rem 1rem;
box-shadow: inset 0px 6px 62px -14px rgba(0, 0, 0, 0.45);
@ -22,7 +22,12 @@
width: 120px;
height: 120px;
flex-shrink: 0;
background-color: #b5b5b5;
background-color: var(--main-darker);
img {
max-width: 100%;
max-height: 100%;
}
}
&-content {

View File

@ -8,8 +8,8 @@
.center-box {
max-width: 800px;
background-color: #2e6b81;
color: #fff;
background-color: var(--main);
color: var(--text-color);
margin: 2rem auto;
padding: 4rem;
position: relative;
@ -22,8 +22,8 @@
}
&-addon {
color: #fff;
background-color: #042b3a;
color: var(--text-color);
background-color: var(--main-dark);
overflow: hidden;
max-width: 600px;
margin: 0 auto 2rem auto;

View File

@ -5,7 +5,7 @@
border: none;
border-radius: 4px;
cursor: pointer;
outline: 0px solid #00c0ff8a;
outline: 0px solid var(--focus-outline);
background-color: var(--btn-background);
color: var(--btn-color);
@ -14,18 +14,23 @@
&-link {
font-size: 1rem;
color: #fff;
color: var(--text-color);
text-shadow: var(--text-shadow) 1px 1px 2px;
padding: 12px;
}
text-shadow: none;
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
&:hover {
background-color: var(--btn-background-hover);
}
&:focus {
outline: 4px solid #00c0ff8a;
outline: 4px solid var(--focus-outline);
}
&-primary {

18
src/fe/scss/_colors.scss Normal file
View File

@ -0,0 +1,18 @@
:root {
--black: #000;
--white: #fff;
--text-color: var(--white);
--text-shadow: var(--black);
--form-border: #707070;
--form-border-hover: #5c5c5c;
--main-background: #314550;
--main: #2e6b81;
--main-light: #519eb9;
--main-darker: #005b74;
--main-dark: #042b3a;
--focus-outline: #00c0ff8a;
}

View File

@ -19,16 +19,16 @@ input.form-control {
padding: 8px;
font-size: 1rem;
border-radius: 4px;
border: 1px solid #707070;
border: 1px solid var(--form-border);
transition: outline 0.15s linear;
&:focus {
outline: 4px solid #00c0ff8a;
outline: 4px solid var(--focus-outline);
}
&:hover,
&:focus {
border: 1px solid #5c5c5c;
border: 1px solid var(--form-border-hover);
}
}

View File

@ -12,7 +12,7 @@
&__content {
max-width: 800px;
background-color: #2e6b81;
background-color: var(--main);
box-shadow: 0px 6px 62px -14px rgba(0, 0, 0, 0.45);
margin: 4rem auto 0 auto;
}
@ -36,7 +36,7 @@
display: flex;
flex-direction: row;
justify-content: flex-end;
border-top: 1px solid #005b74;
border-top: 1px solid var(--main-darker);
gap: 1rem;
}
@ -46,7 +46,7 @@
padding: 1rem;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #005b74;
border-bottom: 1px solid var(--main-darker);
&-button .btn {
min-width: initial;

View File

@ -8,7 +8,7 @@
&__nav {
padding: 2rem 0rem;
background-color: #005b74;
background-color: var(--main-darker);
ul {
display: flex;
@ -31,13 +31,13 @@
text-decoration: underline;
}
&.active,
&:focus-visible {
border-right-color: #519eb9;
&.active {
border-right-color: var(--main-light);
font-weight: bold;
}
&:focus {
outline: 4px solid #00c0ff8a;
outline: 4px solid var(--focus-outline);
}
}
}
@ -56,6 +56,7 @@
height: 120px;
width: 120px;
flex: 0 0 120px;
background-color: var(--main-darker);
margin: auto;
}
}
@ -78,7 +79,64 @@
#crop-result {
max-width: 100%;
max-height: 50vh;
background-color: #005b74;
background-color: var(--main-darker);
}
}
.alert {
margin-bottom: 1rem;
}
.authorization {
&__client {
display: flex;
flex-direction: row;
min-height: 120px;
margin-bottom: 1rem;
&-image {
width: 120px;
height: 120px;
flex-shrink: 0;
background-color: var(--main-darker);
img {
max-width: 100%;
max-height: 100%;
}
}
&-content {
display: flex;
flex-direction: column;
flex-grow: 1;
padding: 0 8px;
}
&-title {
font-size: 1.5rem;
}
&-urls {
margin-top: auto;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
}
&-url {
margin-top: 2px;
}
&-description {
margin-bottom: 4px;
}
@include break-on(xs, down) {
flex-direction: column;
align-items: center;
gap: 1rem;
}
}
}
}

View File

@ -1,4 +1,5 @@
@import 'breakpoint';
@import 'colors';
@import 'block';
@import 'form';
@import 'button';
@ -23,12 +24,12 @@ body {
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: #314550;
text-shadow: black 1px 1px 2px;
background-color: var(--main-background);
text-shadow: var(--text-shadow) 1px 1px 2px;
}
a {
color: #fff;
color: var(--text-color);
&:hover {
text-decoration: none;

View File

@ -44,6 +44,10 @@ export class AvatarModal extends Modal {
public initialize(): void {
super.initialize();
if (!this.modal) {
return;
}
this.csrf = (document.querySelector('#csrf') as HTMLInputElement).value;
this.stages = this.modal?.querySelectorAll(
'[data-upload-step]',
@ -131,7 +135,7 @@ export class AvatarModal extends Modal {
this.uploadBtn.addEventListener('click', () => {
const formData = new FormData();
formData.append('file', this.cropResultBlob);
formData.append('csrf', this.csrf);
formData.append('_csrf', this.csrf);
// TODO: error
fetch('/account/avatar', {

View File

@ -1,6 +1,11 @@
const isVisible = (elem: HTMLElement) =>
!!elem &&
!!(elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length);
export class Modal {
public triggers?: NodeListOf<HTMLElement>;
public modal?: HTMLElement;
public modalContent?: HTMLElement;
protected focusLock: HTMLElement[] = [];
protected trigger?: HTMLElement;
@ -13,6 +18,7 @@ export class Modal {
this.modal.style.display = 'none';
this.removeFocusLock();
this.removeClickListener();
}
public open(): void {
@ -22,6 +28,10 @@ export class Modal {
this.modal.style.display = 'block';
this.createFocusLock();
setTimeout(() =>
document.addEventListener('click', this.outsideClickListener),
);
}
public initialize(): void {
@ -44,12 +54,28 @@ export class Modal {
if (this.modal) {
const attrLabel = `modal_${this.name}_label`;
const label = this.modal.querySelector('.modal__title');
this.modalContent = this.modal.querySelector('.modal__content');
this.modal.setAttribute('aria-modal', 'true');
this.modal.setAttribute('role', 'dialog');
this.modal.setAttribute('aria-labelledby', attrLabel);
label.setAttribute('id', attrLabel);
}
}
private outsideClickListener = (event: Event) => {
if (
!this.modalContent.contains(event.target as HTMLElement) &&
isVisible(this.modalContent)
) {
this.reset();
this.removeClickListener();
}
};
private removeClickListener = () => {
document.removeEventListener('click', this.outsideClickListener);
};
private getFocusable(): HTMLElement[] {
const focusable = Array.from(
this.modal.querySelectorAll(

View File

@ -14,6 +14,12 @@ export class ModalManager {
this.close();
});
});
window.addEventListener('keyup', (evt: KeyboardEvent) => {
if (evt.key === 'Escape') {
this.close();
}
});
}
public register<T extends Modal>(item: T): T {

View File

@ -2,6 +2,8 @@ import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as dotenv from 'dotenv';
import * as session from 'express-session';
import * as connectRedis from 'connect-redis';
import * as redis from 'redis';
import { join } from 'path';
import { NestExpressApplication } from '@nestjs/platform-express';
@ -9,11 +11,22 @@ dotenv.config();
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const RedisStore = connectRedis(session);
const redisClient = redis.createClient({
host: 'localhost',
port: 6379,
});
// app.use(express.urlencoded());
// app.use(cookieParser());
app.use(
session({
secret: process.env.SESSION_SECRET,
resave: true,
saveUninitialized: false,
store: new RedisStore({ client: redisClient }),
cookie: {
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',

View File

@ -7,6 +7,7 @@ export class CSRFMiddleware implements NestMiddleware {
constructor(private readonly tokenService: TokenService) {}
use(req: Request, res: Response, next: NextFunction) {
// TODO: do not store in session, keep the amount of pointless sessions down
if (!req.session.csrf) {
req.session.csrf = this.tokenService.generateString(64);
}

View File

@ -9,7 +9,7 @@ export class ValidateCSRFMiddleware implements NestMiddleware {
return next();
}
if (req.body.csrf !== req.session.csrf) {
if (req.body._csrf !== req.session.csrf) {
return next(new Error('Invalid session'));
}

View File

@ -35,11 +35,8 @@ export class LoginController {
@Get()
@Render('login/login')
public loginView(
@Session() session: SessionData,
@Req() req: Request,
): Record<string, any> {
return this.formUtil.populateTemplate(req, session);
public loginView(@Req() req: Request): Record<string, any> {
return this.formUtil.populateTemplate(req);
}
@Post()
@ -232,14 +229,14 @@ export class LoginController {
}
res.render('login/password', {
...this.formUtil.populateTemplate(req, session),
...this.formUtil.populateTemplate(req),
token: true,
});
return;
}
res.render('login/password', {
...this.formUtil.populateTemplate(req, session),
...this.formUtil.populateTemplate(req),
token: false,
});
}

View File

@ -44,7 +44,11 @@ export class UserAdapter implements OAuth2UserAdapter {
clientId: string,
scope: string | string[],
): Promise<boolean> {
return false;
return this._service.clientService.hasAuthorized(
userId,
clientId,
this._service.splitScope(scope),
);
}
async consent(
@ -52,6 +56,13 @@ export class UserAdapter implements OAuth2UserAdapter {
clientId: string,
scope: string | string[],
): Promise<boolean> {
const client = await this._service.clientService.getById(clientId);
const user = await this._service.userService.getById(userId);
await this._service.clientService.createAuthorization(
user,
client,
this._service.splitScope(scope),
);
return true;
}
}

View File

@ -64,11 +64,15 @@ export class OAuth2Service {
}
}
public splitScope(scope: string): string[] {
public splitScope(scope: string | string[]): string[] {
if (!scope) {
return [];
}
if (Array.isArray(scope)) {
return scope;
}
return scope.includes(',')
? scope.split(',').map((item) => item.trim())
: scope.split(' ');

View File

@ -25,11 +25,8 @@ export class RegisterController {
@Get()
@Render('register')
public registerView(
@Session() session: SessionData,
@Req() req: Request,
): Record<string, any> {
return this.formUtil.populateTemplate(req, session);
public registerView(@Req() req: Request): Record<string, any> {
return this.formUtil.populateTemplate(req);
}
@Post()

View File

@ -1,19 +1,25 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Param,
Post,
Redirect,
Render,
Req,
Res,
Session,
UnauthorizedException,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Request } from 'express';
import { Request, Response } from 'express';
import { SessionData } from 'express-session';
import { unlink } from 'fs/promises';
import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service';
import { UploadService } from 'src/modules/objects/upload/upload.service';
import { UserService } from 'src/modules/objects/user/user.service';
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
@ -26,6 +32,7 @@ export class SettingsController {
private readonly _form: FormUtilityService,
private readonly _upload: UploadService,
private readonly _user: UserService,
private readonly _client: OAuth2ClientService,
) {}
@Get()
@ -36,8 +43,42 @@ export class SettingsController {
@Get('general')
@Render('settings/general')
public general(@Req() req: Request, @Session() sess: SessionData) {
return this._form.populateTemplate(req, sess, { user: req.user });
public general(@Req() req: Request) {
return this._form.populateTemplate(req, { user: req.user });
}
@Post('general')
public async updateDisplayName(
@Req() req: Request,
@Res() res: Response,
@Body() body: { display_name?: string },
) {
try {
const { display_name } = body;
if (!display_name) {
throw new Error('Display name is required.');
}
if (display_name.length < 3 || display_name.length > 32) {
throw new Error(
'Display name must be between 3 and 32 characters long.',
);
}
req.user.display_name = display_name;
await this._user.updateUser(req.user);
req.flash('message', {
error: false,
text: 'Display name has been changed!',
});
} catch (e: any) {
req.flash('message', {
error: true,
text: e.message,
});
}
res.redirect('/account/general');
}
@Post('avatar')
@ -72,4 +113,55 @@ export class SettingsController {
file: upload.file,
};
}
@Post('avatar/delete')
public async deleteUserAvatar(@Req() req: Request, @Res() res: Response) {
this._user.deleteAvatar(req.user);
req.flash('message', {
error: false,
text: 'Avatar removed successfully.',
});
res.redirect('/account/general');
}
@Get('oauth2')
@Render('settings/oauth2')
public async authorizations(@Req() req: Request) {
const authorizations = await this._client.getAuthorizations(req.user);
return this._form.populateTemplate(req, { authorizations });
}
@Post('oauth2/revoke/:id')
public async revokeAuthorization(
@Req() req: Request,
@Res() res: Response,
@Param('id') id: number,
) {
const getAuth = await this._client.getAuthorization(req.user, id);
const jsreq =
req.header('content-type').startsWith('application/json') ||
req.header('accept').startsWith('application/json');
if (!getAuth) {
if (jsreq) {
throw new UnauthorizedException(
'Unauthorized or invalid revokation request',
);
}
req.flash('message', {
error: true,
text: 'Unauthorized revokation.',
});
res.redirect('/account/oauth2');
return;
}
await this._client.revokeAuthorization(getAuth);
if (jsreq) {
return res.json({ success: true });
}
res.redirect('/account/oauth2');
}
}

View File

@ -18,6 +18,7 @@ import { UserModule } from 'src/modules/objects/user/user.module';
import { OAuth2Module } from '../oauth2/oauth2.module';
import { SettingsController } from './settings.controller';
import { SettingsService } from './settings.service';
import { OAuth2ClientModule } from 'src/modules/objects/oauth2-client/oauth2-client.module';
@Module({
controllers: [SettingsController],
@ -26,6 +27,7 @@ import { SettingsService } from './settings.service';
UploadModule,
UserModule,
OAuth2Module,
OAuth2ClientModule,
MulterModule.registerAsync({
imports: [ConfigurationModule],
useFactory: async (config: ConfigurationService) => {

View File

@ -42,7 +42,7 @@ export class TwoFactorController {
const qrcode = await this.qr.createQRDataURI(url);
res.render('two-factor/activate', {
...this.form.populateTemplate(req, session),
...this.form.populateTemplate(req),
qrcode,
});

View File

@ -1,5 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { DeleteResult, Repository } from 'typeorm';
import { User } from '../user/user.entity';
import { OAuth2ClientAuthorization } from './oauth2-client-authorization.entity';
import {
OAuth2ClientURL,
@ -18,6 +19,98 @@ export class OAuth2ClientService {
private clientAuthRepository: Repository<OAuth2ClientAuthorization>,
) {}
public async hasAuthorized(
userId: number,
clientId: string,
scope: string[],
): Promise<boolean> {
const authorization = await this.clientAuthRepository.findOne({
where: {
user: {
id: userId,
},
client: {
client_id: clientId,
},
},
relations: ['user', 'client'],
});
if (!authorization) {
return false;
}
// Scopes must have been allowed
const splitScope = authorization.scope.split(' ');
if (scope.every((item) => splitScope.includes(item))) {
return true;
}
return false;
}
public async createAuthorization(
user: User,
client: OAuth2Client,
scope: string[],
): Promise<OAuth2ClientAuthorization> {
const existing = await this.clientAuthRepository.findOne({
where: {
user,
client,
},
relations: ['user', 'client'],
});
if (existing) {
const splitScope = existing.scope.split(' ');
scope.forEach((item) => {
if (!splitScope.includes(item)) {
splitScope.push(item);
}
});
existing.scope = splitScope.join(' ');
await this.clientAuthRepository.save(existing);
return existing;
}
const authorization = new OAuth2ClientAuthorization();
authorization.user = user;
authorization.client = client;
authorization.scope = scope.join(' ');
await this.clientAuthRepository.insert(authorization);
return authorization;
}
public async getAuthorizations(
user: User,
): Promise<OAuth2ClientAuthorization[]> {
return this.clientAuthRepository.find({
relations: ['user', 'client', 'client.urls'],
where: { user },
});
}
public async revokeAuthorization(
auth: OAuth2ClientAuthorization,
): Promise<OAuth2ClientAuthorization> {
console.log(auth);
return this.clientAuthRepository.remove(auth);
}
public async getAuthorization(
user: User,
authId: number,
): Promise<OAuth2ClientAuthorization> {
return this.clientAuthRepository.findOne({
where: {
user,
id: authId,
},
relations: ['user'],
});
}
public async getById(id: string | number): Promise<OAuth2Client> {
let client: OAuth2Client;

View File

@ -77,6 +77,12 @@ export class UserService {
return user;
}
public async deleteAvatar(user: User): Promise<void> {
if (user.picture) {
await this.upload.delete(user.picture);
}
}
public async comparePasswords(
hash: string,
password: string,

View File

@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Request } from 'express';
import { SessionData } from 'express-session';
@Injectable()
export class FormUtilityService {
@ -25,7 +24,6 @@ export class FormUtilityService {
*/
public populateTemplate(
req: Request,
session: SessionData,
additional: Record<string, any> = {},
): Record<string, any> {
const message = req.flash('message')[0] || {};
@ -35,7 +33,7 @@ export class FormUtilityService {
return {
path: req.originalUrl,
csrf: session.csrf,
csrf: req.session.csrf,
message,
form,
...additional,

View File

@ -12,12 +12,20 @@ block body
.authorize
.authorize__user
.authorize__user-image
if user.picture
img(src='/uploads/' + user.picture.file, alt=user.username)
else
img(src='/public/image/avatar.png', alt='No picture')
.authorize__user-content
span.authorize__user-title #{user.display_name}
span.authorize__user-user @#{user.username}
.authorize__center
.authorize__client
.authorize__client-image
if client.picture
img(src='/uploads/' + client.picture.file, alt=client.title)
else
img(src='/public/image/application.png', alt='No picture')
.authorize__client-content
span.authorize__client-title #{client.title}
span.authorize__client-description #{client.description}
@ -45,12 +53,12 @@ block body
form(method="POST", action="")
div.form-container
input(type="hidden", name="csrf", value=csrf)
input(type="hidden", name="_csrf", value=csrf)
input(type="hidden", name="decision", value="1")
button.btn.btn-primary(type="submit") Authorize
form(method="POST", action="")
div.form-container
input(type="hidden", name="csrf", value=csrf)
input(type="hidden", name="_csrf", value=csrf)
input(type="hidden", name="decision", value="0")
button.btn.btn-link(type="submit") Reject

View File

@ -18,7 +18,7 @@ block body
form(method="post")
div.form-container
input#csrf(type="hidden", name="csrf", value=csrf)
input#csrf(type="hidden", name="_csrf", value=csrf)
label.form-label(for="username") Username
input.form-control#username(type="text", name="username", placeholder="Username", autofocus, value=form.username)
label.form-label(for="password") Password

View File

@ -19,7 +19,7 @@ block body
form(method="post")
div.form-container
input#csrf(type="hidden", name="csrf", value=csrf)
input#csrf(type="hidden", name="_csrf", value=csrf)
label.form-label(for="password") New password
input.form-control#password(type="password", name="password", autofocus, placeholder="Password")
small.form-hint Must be at least 8 characters long, contain a capital and lowercase letter and a number.
@ -31,7 +31,7 @@ block body
p If you have forgotten your password, please enter your accounts email address and we will send you a link to recover it.
form(method="post")
div.form-container
input#csrf(type="hidden", name="csrf", value=csrf)
input#csrf(type="hidden", name="_csrf", value=csrf)
label.form-label(for="email") Email address
input.form-control#email(type="email", name="email", autofocus, placeholder="Email addres")
button.btn.btn-primary(type="submit") Send recovery email

View File

@ -18,7 +18,7 @@ block body
form(method="post")
div.form-container
input#csrf(type="hidden", name="csrf", value=csrf)
input#csrf(type="hidden", name="_csrf", value=csrf)
label.form-label(for="totp") Code
input.form-control#totp(type="text", name="totp", autofocus, placeholder="xxxxxx")
button.btn.btn-primary(type="submit") Log in

View File

@ -18,7 +18,7 @@ block body
form(method="post")
div.form-container
input#csrf(type="hidden", name="csrf", value=csrf)
input#csrf(type="hidden", name="_csrf", value=csrf)
label.form-label(for="username") Username
input.form-control#username(type="text", name="username", placeholder="Username", autofocus, value=form.username)

View File

@ -16,7 +16,7 @@ block settings
.col
form(method="post")
div.form-container
input#csrf(type="hidden", name="csrf", value=csrf)
input#csrf(type="hidden", name="_csrf", value=csrf)
label.form-label(for="username") Username
input.form-control#username(type="text", name="username", placeholder="Username", disabled, value=user.username)
label.form-label(for="display_name") Display Name
@ -33,16 +33,18 @@ block settings
.flex-column(data-script="flex")
button.btn.btn-primary(data-modal-trigger="avatar") Change avatar
if user.picture
button.btn.btn-link#remove-avatar Remove avatar
form(method="post", action="/account/avatar/delete")
input(type="hidden", name="_csrf", value=csrf)
button.btn.btn-link#remove-avatar(type="submit") Remove avatar
form(method="post", data-noscript, action="/account/avatar", enctype="multipart/form-data")
div.form-container
input#csrf(type="hidden", name="csrf", value=csrf)
input(type="hidden", name="_csrf", value=csrf)
label.form-label(for="image") Image
input.form-control#image(type="file", name="file")
small.form-hint Must be less than 10 MB and 1:1 aspect ratio. Enable JavaScript to custom crop your image.
button.btn.btn-primary(type="submit") Change
.modal#avatar-modal(data-modal="avatar", aria-live="polite", role="modal", style="display: none")
.modal#avatar-modal(data-modal="avatar", style="display: none")
.modal__content
.modal__title
|Change avatar

View File

@ -7,10 +7,10 @@ block body
nav.sidebar.settings__nav
ul
li
a(href="/account/general") General
a(href="/account/general", class=path === '/account/general' ? 'active' : '') General
li
a(href="/account/oauth2") Authorizations
a(href="/account/oauth2", class=path === '/account/oauth2' ? 'active' : '') Authorizations
li
a(href="/account/security") Security
a(href="/account/security", class=path === '/account/security' ? 'active' : '') Security
section.content.settings__content
block settings

44
views/settings/oauth2.pug Normal file
View File

@ -0,0 +1,44 @@
extends ./layout.pug
block title
|Authorizations - Account settings | Icy Network
block settings
h1 Authorizations
if message.text
if message.error
.alert.alert-danger
span #{message.text}
else
.alert.alert-success
span #{message.text}
p These applications are authorized automatically when requested, provided you have already consented to the information they require.
p By revoking the authorization, you may be prompted to authorize the application again in the future. This does NOT ensure that your information
|is deleted by any third-party applications in question. Please contact each application's owner individually if you wish to remove your information
|from their servers.
hr
.authorization-wrapper
each auth in authorizations
.authorization__client
.authorization__client-image
if auth.client.picture
img(src='/uploads/' + auth.client.picture.file, alt=auth.client.title)
else
img(src='/public/image/application.png', alt='No picture')
.authorization__client-content
span.authorization__client-title #{auth.client.title}
span.authorization__client-description #{auth.client.description}
.authorization__client-urls
each url in auth.client.urls
if url.type == 'website'
a.authorization__client-url(href=url.url, target="_blank", rel="nofollow") Visit website
if url.type == 'privacy'
a.authorization__client-url(href=url.url, target="_blank", rel="nofollow") Privacy Policy
if url.type == 'terms'
a.authorization__client-url(href=url.url, target="_blank", rel="nofollow") Terms of Service
.authorization__client-actions
form(method="post",action="/account/oauth2/revoke/" + auth.id)
input(type="hidden", name="_csrf", value=csrf)
button.btn.btn-primary(type="submit") Revoke
if !authorizations.length
p.text-center You have not authorized any applications.

View File

@ -21,7 +21,7 @@ block body
form(method="post",autocomplete="off")
div.form-container
input#csrf(type="hidden", name="csrf", value=csrf)
input#csrf(type="hidden", name="_csrf", value=csrf)
label.form-label(for="code") Code from authenticator app
input.form-control#code(type="text", name="code", autofocus, placeholder="xxxxxx")
button.btn.btn-primary(type="submit") Activate