webpack frontend stuff, jwt start, settings start

This commit is contained in:
Evert Prants 2022-03-19 12:25:37 +02:00
parent dc7f4215af
commit d7fc152c8a
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
41 changed files with 3976 additions and 61 deletions

1
.gitignore vendored
View File

@ -38,6 +38,7 @@ lerna-debug.log*
.env
/devdocker
/config*.toml
/private
# front-end items
/public/js

3669
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,13 +8,13 @@
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build && npm run build:fe",
"build:fe": "rimraf public/css && sass --no-source-map --style=compressed src/scss/_index.scss:public/css/index.css",
"build:fe": "rimraf public/css public/js && NODE_ENV=production webpack --mode=production",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"start:fe": "rimraf public/css && sass --watch --update --style=expanded src/scss/_index.scss:public/css/index.css",
"start:fe": "rimraf public/css public/js && webpack --mode=development -w",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
@ -33,6 +33,7 @@
"class-validator": "^0.13.2",
"dotenv": "^16.0.0",
"express-session": "^1.17.2",
"jsonwebtoken": "^8.5.1",
"mysql2": "^2.3.3",
"nodemailer": "^6.7.2",
"otplib": "^12.0.1",
@ -46,6 +47,8 @@
"uuid": "^8.3.2"
},
"devDependencies": {
"@babel/preset-env": "^7.16.11",
"@babel/preset-typescript": "^7.16.7",
"@nestjs/cli": "^8.0.0",
"@nestjs/schematics": "^8.0.0",
"@nestjs/testing": "^8.0.0",
@ -53,6 +56,7 @@
"@types/express": "^4.17.13",
"@types/express-session": "^1.17.4",
"@types/jest": "27.4.1",
"@types/jsonwebtoken": "^8.5.8",
"@types/node": "^16.0.0",
"@types/nodemailer": "^6.4.4",
"@types/qrcode": "^1.4.2",
@ -60,19 +64,26 @@
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"babel-loader": "^8.2.3",
"css-loader": "^6.7.1",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "^27.2.5",
"mini-css-extract-plugin": "^2.6.0",
"prettier": "^2.3.2",
"sass": "^1.49.9",
"sass-loader": "^12.6.0",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"text-loader": "^0.0.1",
"ts-jest": "^27.0.3",
"ts-loader": "^9.2.3",
"ts-node": "^10.0.0",
"tsconfig-paths": "^3.10.1",
"typescript": "^4.3.5"
"typescript": "^4.3.5",
"webpack": "^5.70.0",
"webpack-cli": "^4.9.2"
},
"jest": {
"moduleFileExtensions": [

View File

@ -8,6 +8,7 @@ import { ConfigurationModule } from './modules/config/config.module';
import { LoginModule } from './modules/features/login/login.module';
import { OAuth2Module } from './modules/features/oauth2/oauth2.module';
import { RegisterModule } from './modules/features/register/register.module';
import { SettingsModule } from './modules/features/settings/settings.module';
import { TwoFactorModule } from './modules/features/two-factor/two-factor.module';
import { DatabaseModule } from './modules/objects/database/database.module';
import { EmailModule } from './modules/objects/email/email.module';
@ -35,6 +36,7 @@ import { UtilityModule } from './modules/utility/utility.module';
RegisterModule,
OAuth2Module,
TwoFactorModule,
SettingsModule,
],
controllers: [AppController],
providers: [AppService, CSRFMiddleware],

View File

@ -0,0 +1,47 @@
.settings {
max-width: 1000px;
display: flex;
flex-direction: row;
padding: 0;
&__nav {
padding: 2rem 0rem;
background-color: #005b74;
ul {
display: flex;
flex-direction: column;
list-style: none;
margin: 0;
padding: 0;
li {
display: flex;
flex-direction: column;
a {
text-decoration: none;
padding: 1rem;
border-right: 4px solid transparent;
transition: outline 0.15s linear;
&:hover {
text-decoration: underline;
}
&.active,
&:focus-visible {
border-right-color: #519eb9;
}
&:focus {
outline: 4px solid #00c0ff8a;
}
}
}
}
}
&__content {
padding: 2.75rem;
}
}

View File

@ -5,6 +5,7 @@
@import 'flex';
@import 'alert';
@import 'authorize';
@import 'settings';
*,
*::before,

0
src/fe/ts/index.ts Normal file
View File

View File

@ -8,6 +8,12 @@ export interface SMTPConfiguration {
};
}
export interface JWTConfiguration {
algorithm: string;
issuer: string;
expiration: number;
}
export interface EmailConfiguration {
from: string;
smtp: SMTPConfiguration;
@ -22,4 +28,5 @@ export interface AppConfiguration {
export interface Configuration {
app: AppConfiguration;
email: EmailConfiguration;
jwt: JWTConfiguration;
}

View File

@ -31,6 +31,11 @@ export const configProviders = [
},
},
},
jwt: {
algorithm: 'RS256',
issuer: 'localhost',
expiration: 3600,
},
} as Configuration,
},
{

View File

@ -18,8 +18,8 @@ export class LoginModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(ValidateCSRFMiddleware)
.forRoutes({ path: '*', method: RequestMethod.POST });
.forRoutes({ path: 'login*', method: RequestMethod.POST });
consumer.apply(FlashMiddleware).forRoutes('*');
consumer.apply(FlashMiddleware).forRoutes('login*');
}
}

View File

@ -79,10 +79,16 @@ export class OAuth2Controller {
uuid: user.uuid,
username: user.username,
display_name: user.display_name,
// Standard claims
name: user.display_name,
preferred_username: user.username,
nickname: user.display_name,
};
if (token.scope.includes('email') || token.scope.includes('user:email')) {
userData.email = user.email;
userData.email_verified = true;
}
if (

View File

@ -17,8 +17,8 @@ export class RegisterModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(ValidateCSRFMiddleware)
.forRoutes({ path: '*', method: RequestMethod.POST });
.forRoutes({ path: 'register*', method: RequestMethod.POST });
consumer.apply(FlashMiddleware).forRoutes('*');
consumer.apply(FlashMiddleware).forRoutes('register*');
}
}

View File

@ -0,0 +1,32 @@
import {
Controller,
Get,
Redirect,
Render,
Req,
Session,
} from '@nestjs/common';
import { Request } from 'express';
import { SessionData } from 'express-session';
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
import { SettingsService } from './settings.service';
@Controller('/account')
export class SettingsController {
constructor(
private readonly _service: SettingsService,
private readonly _form: FormUtilityService,
) {}
@Get()
@Redirect('/account/general')
public redirectGeneral() {
return;
}
@Get('general')
@Render('settings/general')
public general(@Req() req: Request, @Session() sess: SessionData) {
return this._form.populateTemplate(req, sess);
}
}

View File

@ -0,0 +1,34 @@
import {
MiddlewareConsumer,
Module,
NestModule,
RequestMethod,
} from '@nestjs/common';
import { AuthMiddleware } from 'src/middleware/auth.middleware';
import { FlashMiddleware } from 'src/middleware/flash.middleware';
import { ValidateCSRFMiddleware } from 'src/middleware/validate-csrf.middleware';
import { ConfigurationModule } from 'src/modules/config/config.module';
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';
@Module({
controllers: [SettingsController],
imports: [ConfigurationModule, UserModule, OAuth2Module],
providers: [SettingsService],
})
export class SettingsModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(ValidateCSRFMiddleware)
.forRoutes(
{ path: '/account*', method: RequestMethod.POST },
{ path: '/account*', method: RequestMethod.PATCH },
{ path: '/account*', method: RequestMethod.DELETE },
);
consumer.apply(AuthMiddleware).forRoutes('account*');
consumer.apply(FlashMiddleware).forRoutes('account*');
}
}

View File

@ -0,0 +1,11 @@
import { ConfigurationService } from 'src/modules/config/config.service';
import { UserService } from 'src/modules/objects/user/user.service';
import { OAuth2Service } from '../oauth2/oauth2.service';
export class SettingsService {
constructor(
public user: UserService,
public oauth: OAuth2Service,
public config: ConfigurationService,
) {}
}

View File

@ -11,7 +11,7 @@ import { TwoFactorController } from './two-factor.controller';
})
export class TwoFactorModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(AuthMiddleware).forRoutes('/account/two-factor/activate');
consumer.apply(FlashMiddleware).forRoutes('*');
consumer.apply(AuthMiddleware).forRoutes('account/two-factor/activate');
consumer.apply(FlashMiddleware).forRoutes('account/two-factor/activate');
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ConfigurationModule } from '../config/config.module';
import { jwtProviders } from './jwt.providers';
import { JWTService } from './jwt.service';
@Module({
imports: [ConfigurationModule],
providers: [...jwtProviders, JWTService],
exports: [JWTService],
})
export class JWTModule {}

View File

@ -0,0 +1,19 @@
import { join } from 'path';
import { readFile } from 'fs/promises';
export const jwtProviders = [
{
provide: 'PRIVATE_PATH',
useValue: join(__dirname, '..', '..', '..', 'private'),
},
{
provide: 'JWT_PRIVATE_KEY',
useFactory: async (path: string) => readFile(join(path, 'jwt.private.pem')),
inject: ['PRIVATE_PATH'],
},
{
provide: 'JWT_PUBLIC_KEY',
useFactory: async (path: string) => readFile(join(path, 'jwt.public.pem')),
inject: ['PRIVATE_PATH'],
},
];

View File

@ -0,0 +1,39 @@
import { Inject, Injectable } from '@nestjs/common';
import { ConfigurationService } from '../config/config.service';
import * as jwt from 'jsonwebtoken';
@Injectable()
export class JWTService {
constructor(
@Inject('JWT_PRIVATE_KEY') private _privateKey: string,
@Inject('JWT_PUBLIC_KEY') private _publicKey: string,
private _config: ConfigurationService,
) {}
public issue(
claims: Record<string, any>,
subject: string,
audience?: string,
): string {
return jwt.sign(claims, this._privateKey, {
algorithm: this._config.get('jwt.algorithm'),
issuer: this._config.get('jwt.issuer'),
expiresIn: this._config.get('jwt.expiration'),
subject,
audience,
});
}
public verify(
token: string,
subject?: string,
audience?: string,
): jwt.JwtPayload {
return jwt.verify(token, this._publicKey, {
algorithms: [this._config.get('jwt.algorithm')],
issuer: this._config.get('jwt.issuer'),
subject,
audience,
}) as jwt.JwtPayload;
}
}

View File

@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
import { ConfigurationModule } from 'src/modules/config/config.module';
import { databaseProviders } from './database.providers';
@Module({
imports: [ConfigurationModule],
providers: [...databaseProviders],
exports: [...databaseProviders],
})

View File

@ -1,19 +1,14 @@
import { createConnection } from 'typeorm';
import { ConfigurationService } from 'src/modules/config/config.service';
import { ConnectionOptions, createConnection } from 'typeorm';
export const databaseProviders = [
{
provide: 'DATABASE_CONNECTION',
useFactory: async () =>
await createConnection({
type: 'mysql',
host: 'localhost',
port: 3306,
username: 'icyauth',
password: 'icyauth',
database: 'icyauth',
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
synchronize: true,
logging: ['query', 'error'],
}),
useFactory: async (config: ConfigurationService) => {
return await createConnection({
...config.get<ConnectionOptions>('database'),
});
},
inject: [ConfigurationService],
},
];

View File

@ -0,0 +1,14 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { join } = require('path');
const dotenv = require('dotenv');
const { readFileSync } = require('fs');
const toml = require('toml');
dotenv.config();
const CONFIG_ENV = process.env.NODE_ENV === 'production' ? 'prod' : 'dev';
const CONFIG_FILENAME = process.env.CONFIG || `config.${CONFIG_ENV}.toml`;
const CONFIG_PATH = join(process.cwd(), CONFIG_FILENAME);
const config = toml.parse(readFileSync(CONFIG_PATH, { encoding: 'utf-8' }));
module.exports = config.database;

View File

@ -20,16 +20,25 @@ export class FormUtilityService {
* Include CSRF token, messages and prefilled form values for a template with a form
* @param req Express request
* @param session Express session
* @param additional Additional data to pass
* @returns Template locals
*/
public populateTemplate(
req: Request,
session: SessionData,
additional: Record<string, any> = {},
): Record<string, any> {
const message = req.flash('message')[0] || {};
const form = this.mergeObjectArray(
(req.flash('form') as Record<string, any>[]) || [],
);
return { csrf: session.csrf, message, form };
return {
path: req.originalUrl,
csrf: session.csrf,
message,
form,
...additional,
};
}
}

View File

@ -21,5 +21,5 @@
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
},
}

View File

@ -1,7 +1,7 @@
extends partials/layout.pug
block title
|Icy Network | Authorize application
|Authorize application | Icy Network
block body
include partials/logo.pug

View File

@ -1,7 +1,7 @@
extends ../partials/layout.pug
block title
|Icy Network | Log in
|Log in | Icy Network
block body
include ../partials/logo.pug

View File

@ -1,7 +1,7 @@
extends ../partials/layout.pug
block title
|Icy Network | Set a new password
|Set a new password | Icy Network
block body
include ../partials/logo.pug

View File

@ -1,7 +1,7 @@
extends ../partials/layout.pug
block title
|Icy Network | Veify two-factor
|Veify two-factor | Icy Network
block body
include ../partials/logo.pug

View File

@ -14,6 +14,7 @@ html(lang="en")
meta(name="twitter:description", content="Icy Network is a Single-Sign-On (SSO) provider")
block links
link(rel="stylesheet", type="text/css", href="/public/css/index.css")
script(href="/public/js/app.bundle.js")
title
block title
body

View File

@ -1,7 +1,7 @@
extends partials/layout.pug
block title
|Icy Network | Register
|Register | Icy Network
block body
include partials/logo.pug

View File

@ -0,0 +1,7 @@
extends ./layout.pug
block title
|General - Account settings | Icy Network
block settings
h1 General settings

16
views/settings/layout.pug Normal file
View File

@ -0,0 +1,16 @@
extends ../partials/layout.pug
block body
include ../partials/logo.pug
div.container
div.center-box.settings
nav.sidebar.settings__nav
ul
li
a(href="/account/general") General
li
a(href="/account/oauth2") Authorizations
li
a(href="/account/security") Security
section.content.settings__content
block settings

View File

@ -1,7 +1,7 @@
extends ../partials/layout.pug
block title
|Icy Network | Two-factor authentication
|Two-factor authentication | Icy Network
block body
include ../partials/logo.pug

38
webpack.config.js Normal file
View File

@ -0,0 +1,38 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
entry: ['./src/fe/ts/index.ts', './src/fe/scss/index.scss'],
plugins: [
new MiniCssExtractPlugin({
filename: '../css/index.css',
}),
],
module: {
rules: [
{
test: /\.(ts|js)?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-typescript'],
},
},
},
{
test: /\.(css|scss)/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
},
],
},
resolve: {
extensions: ['.ts', '.js'],
},
output: {
path: path.resolve(__dirname, 'public', 'js'),
filename: 'app.bundle.js',
},
devtool: process.env.NODE_ENV !== 'production' ? 'source-map' : undefined,
};