Initial service setup
This commit is contained in:
commit
75a7bcfa48
25
.eslintrc.js
Normal file
25
.eslintrc.js
Normal file
@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: __dirname
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# local development environment files
|
||||
.env
|
||||
/devdocker
|
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
73
README.md
Normal file
73
README.md
Normal file
@ -0,0 +1,73 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo_text.svg" width="320" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
|
||||
</p>
|
||||
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
|
||||
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
```
|
||||
|
||||
## Running the app
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ npm run test
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](LICENSE).
|
4
nest-cli.json
Normal file
4
nest-cli.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
17432
package-lock.json
generated
Normal file
17432
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
88
package.json
Normal file
88
package.json
Normal file
@ -0,0 +1,88 @@
|
||||
{
|
||||
"name": "icynet-auth-server",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"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",
|
||||
"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",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@icynet/oauth2-provider": "git+ssh://git@gitlab.icynet.eu:IcyNetwork/oauth2-provider.git",
|
||||
"@levminer/speakeasy": "^1.3.1",
|
||||
"@nestjs/common": "^8.0.0",
|
||||
"@nestjs/core": "^8.0.0",
|
||||
"@nestjs/platform-express": "^8.0.0",
|
||||
"bcrypt": "^5.0.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"dotenv": "^16.0.0",
|
||||
"express-session": "^1.17.2",
|
||||
"mysql2": "^2.3.3",
|
||||
"pug": "^3.0.2",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.2.0",
|
||||
"typeorm": "^0.2.45",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^8.0.0",
|
||||
"@nestjs/schematics": "^8.0.0",
|
||||
"@nestjs/testing": "^8.0.0",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express-session": "^1.17.4",
|
||||
"@types/jest": "27.4.1",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"jest": "^27.2.5",
|
||||
"prettier": "^2.3.2",
|
||||
"sass": "^1.49.9",
|
||||
"source-map-support": "^0.5.20",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^27.0.3",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "^10.0.0",
|
||||
"tsconfig-paths": "^3.10.1",
|
||||
"typescript": "^4.3.5"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
1
public/css/index.css
Normal file
1
public/css/index.css
Normal file
@ -0,0 +1 @@
|
||||
*,*::before,*::after{box-sizing:border-box}html,body{width:100%;height:100%;margin:0;padding:0}body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Open Sans","Helvetica Neue",sans-serif}
|
22
src/app.controller.spec.ts
Normal file
22
src/app.controller.spec.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
22
src/app.controller.ts
Normal file
22
src/app.controller.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Controller, Get, Res, Session } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { SessionData } from 'express-session';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(
|
||||
@Session() session: SessionData,
|
||||
@Res() res: Response,
|
||||
): Record<string, any> {
|
||||
if (!session.user) {
|
||||
res.redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
res.render('index', { user: session.user });
|
||||
}
|
||||
}
|
38
src/app.module.ts
Normal file
38
src/app.module.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { CSRFMiddleware } from './middleware/csrf.middleware';
|
||||
import { FlashMiddleware } from './middleware/flash.middleware';
|
||||
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 { DatabaseModule } from './modules/objects/database/database.module';
|
||||
import { OAuth2ClientModule } from './modules/objects/oauth2-client/oauth2-client.module';
|
||||
import { OAuth2TokenModule } from './modules/objects/oauth2-token/oauth2-token.module';
|
||||
import { UploadModule } from './modules/objects/upload/upload.module';
|
||||
import { UserModule } from './modules/objects/user/user.module';
|
||||
import { UtilityModule } from './modules/utility/utility.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UtilityModule,
|
||||
DatabaseModule,
|
||||
UserModule,
|
||||
UploadModule,
|
||||
OAuth2ClientModule,
|
||||
OAuth2TokenModule,
|
||||
LoginModule,
|
||||
RegisterModule,
|
||||
OAuth2Module,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService, CSRFMiddleware],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(CSRFMiddleware).forRoutes('*');
|
||||
consumer
|
||||
.apply(FlashMiddleware)
|
||||
.forRoutes('login', 'register', 'login/verify');
|
||||
}
|
||||
}
|
8
src/app.service.ts
Normal file
8
src/app.service.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
32
src/main.ts
Normal file
32
src/main.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as session from 'express-session';
|
||||
import { join } from 'path';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.use(
|
||||
session({
|
||||
secret: process.env.SESSION_SECRET,
|
||||
resave: true,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.useStaticAssets(join(__dirname, '..', 'public'), {
|
||||
prefix: '/public/',
|
||||
});
|
||||
app.setBaseViewsDir(join(__dirname, '..', 'views'));
|
||||
app.setViewEngine('pug');
|
||||
|
||||
await app.listen(3000);
|
||||
}
|
||||
bootstrap();
|
21
src/middleware/auth.middleware.ts
Normal file
21
src/middleware/auth.middleware.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestMiddleware,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class AuthMiddleware implements NestMiddleware {
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.session.user) {
|
||||
if (req.header('content-type')?.includes('application/json')) {
|
||||
throw new UnauthorizedException('Unauthorized');
|
||||
}
|
||||
|
||||
res.redirect('/login?redirectTo=' + encodeURIComponent(req.originalUrl));
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
15
src/middleware/csrf.middleware.ts
Normal file
15
src/middleware/csrf.middleware.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { TokenService } from 'src/modules/utility/services/token.service';
|
||||
|
||||
@Injectable()
|
||||
export class CSRFMiddleware implements NestMiddleware {
|
||||
constructor(private readonly tokenService: TokenService) {}
|
||||
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.session.csrf) {
|
||||
req.session.csrf = this.tokenService.generateString(64);
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
47
src/middleware/flash.middleware.ts
Normal file
47
src/middleware/flash.middleware.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { SessionData } from 'express-session';
|
||||
import { format } from 'util';
|
||||
|
||||
@Injectable()
|
||||
export class FlashMiddleware implements NestMiddleware {
|
||||
private _flash(
|
||||
session: SessionData,
|
||||
type: string,
|
||||
...msg: any[]
|
||||
): Record<string, any> {
|
||||
const msgs = (session.flash = session.flash || {});
|
||||
|
||||
if (type && msg?.length) {
|
||||
let result: string;
|
||||
|
||||
if (Array.isArray(msg[0])) {
|
||||
msg[0].forEach((val) => {
|
||||
(msgs[type] = msgs[type] || []).push(val);
|
||||
});
|
||||
|
||||
return msgs;
|
||||
} else {
|
||||
result = msg.length > 1 ? format(...msg) : msg[0];
|
||||
}
|
||||
|
||||
return (msgs[type] = msgs[type] || []).push(result);
|
||||
} else if (type) {
|
||||
const arr = msgs[type];
|
||||
delete msgs[type];
|
||||
return arr || [];
|
||||
} else {
|
||||
session.flash = {};
|
||||
return msgs;
|
||||
}
|
||||
}
|
||||
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
if (req.flash) {
|
||||
return next();
|
||||
}
|
||||
|
||||
req.flash = this._flash.bind(this, req.session);
|
||||
next();
|
||||
}
|
||||
}
|
12
src/middleware/validate-csrf.middleware.ts
Normal file
12
src/middleware/validate-csrf.middleware.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class ValidateCSRFMiddleware implements NestMiddleware {
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
if (req.body.csrf !== req.session.csrf) {
|
||||
return next(new Error('Invalid session'));
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
151
src/modules/features/login/login.controller.ts
Normal file
151
src/modules/features/login/login.controller.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
Render,
|
||||
Req,
|
||||
Res,
|
||||
Session,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { SessionData } from 'express-session';
|
||||
import { User } from 'src/modules/objects/user/user.entity';
|
||||
import { UserService } from 'src/modules/objects/user/user.service';
|
||||
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
|
||||
import { TokenService } from 'src/modules/utility/services/token.service';
|
||||
|
||||
@Controller('/login')
|
||||
export class LoginController {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly formUtil: FormUtilityService,
|
||||
private readonly token: TokenService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Render('login')
|
||||
public loginView(
|
||||
@Session() session: SessionData,
|
||||
@Req() req: Request,
|
||||
): Record<string, any> {
|
||||
return this.formUtil.populateTemplate(req, session);
|
||||
}
|
||||
|
||||
@Post()
|
||||
public async loginRequest(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Body() body: { username: string; password: string },
|
||||
@Query() query: { redirectTo?: string },
|
||||
) {
|
||||
const { username, password } = body;
|
||||
const user = await this.userService.getByUsername(username);
|
||||
|
||||
// User exists and password matches
|
||||
if (
|
||||
!user ||
|
||||
!user.activated ||
|
||||
!(await this.userService.comparePasswords(user.password, password))
|
||||
) {
|
||||
req.flash('form', { username });
|
||||
req.flash('message', {
|
||||
error: true,
|
||||
text: 'Invalid username or password',
|
||||
});
|
||||
|
||||
res.redirect(req.originalUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.userService.userHasTOTP(user)) {
|
||||
const challenge = { type: 'totp', user: user.uuid };
|
||||
req.session.challenge = await this.token.encryptChallenge(challenge);
|
||||
res.redirect(
|
||||
'/login/verify' +
|
||||
(query.redirectTo ? '?redirectTo=' + query.redirectTo : ''),
|
||||
);
|
||||
}
|
||||
|
||||
req.session.user = user;
|
||||
res.redirect(query.redirectTo ? decodeURIComponent(query.redirectTo) : '/');
|
||||
}
|
||||
|
||||
@Get('verify')
|
||||
public verifyUserTokenView(
|
||||
@Session() session: SessionData,
|
||||
@Query() query: { redirectTo?: string },
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (!session.challenge) {
|
||||
req.flash('message', {
|
||||
error: true,
|
||||
text: 'An unexpected error occured, please log in again.',
|
||||
});
|
||||
|
||||
res.redirect(
|
||||
'/login' + (query.redirectTo ? '?redirectTo=' + query.redirectTo : ''),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
res.render('totp-verify', this.formUtil.populateTemplate(req, session));
|
||||
}
|
||||
|
||||
@Post('verify')
|
||||
public async verifyUserToken(
|
||||
@Session() session: SessionData,
|
||||
@Query() query: { redirectTo?: string },
|
||||
@Body() body: { totp: string },
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
let user: User;
|
||||
|
||||
try {
|
||||
if (!session.challenge) {
|
||||
throw new Error('No challenge');
|
||||
}
|
||||
|
||||
const challenge = await this.token.decryptChallenge(session.challenge);
|
||||
if (!challenge || challenge.type !== 'totp' || !challenge.user) {
|
||||
throw new Error('Bad challenge');
|
||||
}
|
||||
|
||||
user = await this.userService.getByUUID(challenge.user);
|
||||
if (!user) {
|
||||
throw new Error('Bad challenge');
|
||||
}
|
||||
} catch (e: any) {
|
||||
req.flash('message', {
|
||||
error: true,
|
||||
text: 'An unexpected error occured, please log in again.',
|
||||
});
|
||||
|
||||
res.redirect(
|
||||
'/login' + (query.redirectTo ? '?redirectTo=' + query.redirectTo : ''),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const totp = await this.userService.getUserTOTP(user);
|
||||
|
||||
if (!this.userService.validateTOTP(totp.token, body.totp)) {
|
||||
throw new Error('Invalid code!');
|
||||
}
|
||||
} catch (e: any) {
|
||||
req.flash('message', {
|
||||
error: true,
|
||||
text: e.message,
|
||||
});
|
||||
res.redirect(req.originalUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
session.challenge = null;
|
||||
res.redirect(query.redirectTo ? decodeURIComponent(query.redirectTo) : '/');
|
||||
}
|
||||
}
|
21
src/modules/features/login/login.module.ts
Normal file
21
src/modules/features/login/login.module.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import {
|
||||
MiddlewareConsumer,
|
||||
Module,
|
||||
NestModule,
|
||||
RequestMethod,
|
||||
} from '@nestjs/common';
|
||||
import { ValidateCSRFMiddleware } from 'src/middleware/validate-csrf.middleware';
|
||||
import { UserModule } from 'src/modules/objects/user/user.module';
|
||||
import { LoginController } from './login.controller';
|
||||
|
||||
@Module({
|
||||
imports: [UserModule],
|
||||
controllers: [LoginController],
|
||||
})
|
||||
export class LoginModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer
|
||||
.apply(ValidateCSRFMiddleware)
|
||||
.forRoutes({ path: '*', method: RequestMethod.POST });
|
||||
}
|
||||
}
|
86
src/modules/features/oauth2/adapter/access-token.adapter.ts
Normal file
86
src/modules/features/oauth2/adapter/access-token.adapter.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import {
|
||||
OAuth2AccessTokenAdapter,
|
||||
OAuth2AccessToken,
|
||||
} from '@icynet/oauth2-provider';
|
||||
import { OAuth2TokenType } from 'src/modules/objects/oauth2-token/oauth2-token.entity';
|
||||
import { OAuth2Service } from '../oauth2.service';
|
||||
|
||||
export class AccessTokenAdapter implements OAuth2AccessTokenAdapter {
|
||||
constructor(private _service: OAuth2Service) {}
|
||||
|
||||
public ttl = 3600;
|
||||
|
||||
getToken(token: OAuth2AccessToken): string {
|
||||
return token.token;
|
||||
}
|
||||
|
||||
async create(
|
||||
userId: number,
|
||||
clientId: string,
|
||||
scope: string | string[],
|
||||
ttl: number,
|
||||
): Promise<string> {
|
||||
const client = await this._service.clientService.getById(clientId);
|
||||
const user = await this._service.userService.getById(userId);
|
||||
const accessToken = this._service.token.generateString(64);
|
||||
|
||||
// Standardize scope value
|
||||
const scopes = (
|
||||
!Array.isArray(scope) ? this._service.splitScope(scope) : scope
|
||||
).join(' ');
|
||||
|
||||
const expiresAt = new Date(Date.now() + ttl * 1000);
|
||||
|
||||
this._service.tokenService.insertToken(
|
||||
accessToken,
|
||||
OAuth2TokenType.ACCESS_TOKEN,
|
||||
client,
|
||||
scopes,
|
||||
expiresAt,
|
||||
user,
|
||||
);
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
async fetchByToken(
|
||||
token: string | OAuth2AccessToken,
|
||||
): Promise<OAuth2AccessToken> {
|
||||
const findBy = typeof token === 'string' ? token : token.token;
|
||||
const find = await this._service.tokenService.fetchByToken(
|
||||
findBy,
|
||||
OAuth2TokenType.ACCESS_TOKEN,
|
||||
);
|
||||
|
||||
return {
|
||||
...find,
|
||||
client_id: find.client.client_id,
|
||||
user_id: find.user.id,
|
||||
};
|
||||
}
|
||||
|
||||
checkTTL(token: OAuth2AccessToken): boolean {
|
||||
return new Date() < token.expires_at;
|
||||
}
|
||||
|
||||
getTTL(token: OAuth2AccessToken): number {
|
||||
return token.expires_at.getTime() - Date.now();
|
||||
}
|
||||
|
||||
async fetchByUserIdClientId(
|
||||
userId: number,
|
||||
clientId: string,
|
||||
): Promise<OAuth2AccessToken> {
|
||||
const find = await this._service.tokenService.fetchByUserIdClientId(
|
||||
userId,
|
||||
clientId,
|
||||
OAuth2TokenType.ACCESS_TOKEN,
|
||||
);
|
||||
|
||||
return {
|
||||
...find,
|
||||
client_id: find.client.client_id,
|
||||
user_id: find.user.id,
|
||||
};
|
||||
}
|
||||
}
|
56
src/modules/features/oauth2/adapter/client.adapter.ts
Normal file
56
src/modules/features/oauth2/adapter/client.adapter.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { OAuth2ClientAdapter, OAuth2Client } from '@icynet/oauth2-provider';
|
||||
import { OAuth2ClientURLType } from 'src/modules/objects/oauth2-client/oauth2-client-url.entity';
|
||||
import { OAuth2Service } from '../oauth2.service';
|
||||
|
||||
export class ClientAdapter implements OAuth2ClientAdapter {
|
||||
constructor(private _service: OAuth2Service) {}
|
||||
|
||||
getId(client: OAuth2Client): string {
|
||||
return client.id as string;
|
||||
}
|
||||
|
||||
async fetchById(id: string): Promise<OAuth2Client> {
|
||||
const find = await this._service.clientService.getById(id);
|
||||
return {
|
||||
id: find.client_id,
|
||||
scope: this._service.splitScope(find.scope),
|
||||
grants: find.grants.split(' '),
|
||||
secret: find.client_secret,
|
||||
};
|
||||
}
|
||||
|
||||
checkSecret(client: OAuth2Client, secret: string): boolean {
|
||||
return client.secret === secret;
|
||||
}
|
||||
|
||||
checkGrantType(client: OAuth2Client, grant: string): boolean {
|
||||
return client.grants.includes(grant);
|
||||
}
|
||||
|
||||
async hasRedirectUri(client: OAuth2Client): Promise<boolean> {
|
||||
const redirectUris = await this._service.clientService.getClientURLs(
|
||||
client.id as string,
|
||||
OAuth2ClientURLType.REDIRECT_URI,
|
||||
);
|
||||
|
||||
return !!redirectUris.length;
|
||||
}
|
||||
|
||||
async checkRedirectUri(
|
||||
client: OAuth2Client,
|
||||
redirectUri: string,
|
||||
): Promise<boolean> {
|
||||
return this._service.clientService.checkRedirectURI(
|
||||
client.id as string,
|
||||
redirectUri,
|
||||
);
|
||||
}
|
||||
|
||||
transformScope(scope: string | string[]): string[] {
|
||||
return Array.isArray(scope) ? scope : this._service.splitScope(scope);
|
||||
}
|
||||
|
||||
checkScope(client: OAuth2Client, scope: string[]): boolean {
|
||||
return scope.every((one) => client.scope.includes(one));
|
||||
}
|
||||
}
|
79
src/modules/features/oauth2/adapter/code.adapter.ts
Normal file
79
src/modules/features/oauth2/adapter/code.adapter.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { OAuth2CodeAdapter, OAuth2Code } from '@icynet/oauth2-provider';
|
||||
import { OAuth2TokenType } from 'src/modules/objects/oauth2-token/oauth2-token.entity';
|
||||
import { OAuth2Service } from '../oauth2.service';
|
||||
|
||||
export class CodeAdapter implements OAuth2CodeAdapter {
|
||||
constructor(private _service: OAuth2Service) {}
|
||||
|
||||
ttl = 3600;
|
||||
|
||||
async create(
|
||||
userId: number,
|
||||
clientId: string,
|
||||
scope: string | string[],
|
||||
ttl: number,
|
||||
): Promise<string> {
|
||||
const client = await this._service.clientService.getById(clientId);
|
||||
const user = await this._service.userService.getById(userId);
|
||||
const accessToken = this._service.token.generateString(64);
|
||||
|
||||
// Standardize scope value
|
||||
const scopes = (
|
||||
!Array.isArray(scope) ? this._service.splitScope(scope) : scope
|
||||
).join(' ');
|
||||
|
||||
const expiresAt = new Date(Date.now() + ttl * 1000);
|
||||
|
||||
this._service.tokenService.insertToken(
|
||||
accessToken,
|
||||
OAuth2TokenType.CODE,
|
||||
client,
|
||||
scopes,
|
||||
expiresAt,
|
||||
user,
|
||||
);
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
async fetchByCode(code: string | OAuth2Code): Promise<OAuth2Code> {
|
||||
const findBy = typeof code === 'string' ? code : code.code;
|
||||
const find = await this._service.tokenService.fetchByToken(
|
||||
findBy,
|
||||
OAuth2TokenType.CODE,
|
||||
);
|
||||
|
||||
return {
|
||||
...find,
|
||||
code: find.token,
|
||||
client_id: find.client.client_id,
|
||||
user_id: find.user.id,
|
||||
};
|
||||
}
|
||||
|
||||
async removeByCode(code: string | OAuth2Code): Promise<boolean> {
|
||||
const findBy = typeof code === 'string' ? code : code.code;
|
||||
const find = await this._service.tokenService.fetchByToken(
|
||||
findBy,
|
||||
OAuth2TokenType.CODE,
|
||||
);
|
||||
this._service.tokenService.remove(find);
|
||||
return true;
|
||||
}
|
||||
|
||||
getUserId(code: OAuth2Code): string {
|
||||
return code.user_id as string;
|
||||
}
|
||||
|
||||
getClientId(code: OAuth2Code): string {
|
||||
return code.client_id as string;
|
||||
}
|
||||
|
||||
getScope(code: OAuth2Code): string {
|
||||
return code.scope;
|
||||
}
|
||||
|
||||
checkTTL(code: OAuth2Code): boolean {
|
||||
return code.expires_at.getTime() > Date.now();
|
||||
}
|
||||
}
|
94
src/modules/features/oauth2/adapter/refresh-token.adapter.ts
Normal file
94
src/modules/features/oauth2/adapter/refresh-token.adapter.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import {
|
||||
OAuth2RefreshTokenAdapter,
|
||||
OAuth2RefreshToken,
|
||||
} from '@icynet/oauth2-provider';
|
||||
import { OAuth2TokenType } from 'src/modules/objects/oauth2-token/oauth2-token.entity';
|
||||
import { OAuth2Service } from '../oauth2.service';
|
||||
|
||||
export class RefreshTokenAdapter implements OAuth2RefreshTokenAdapter {
|
||||
constructor(private _service: OAuth2Service) {}
|
||||
|
||||
invalidateOld = false;
|
||||
|
||||
async create(
|
||||
userId: number,
|
||||
clientId: string,
|
||||
scope: string | string[],
|
||||
): Promise<string> {
|
||||
const client = await this._service.clientService.getById(clientId);
|
||||
const user = await this._service.userService.getById(userId);
|
||||
const accessToken = this._service.token.generateString(64);
|
||||
|
||||
// Standardize scope value
|
||||
const scopes = (
|
||||
!Array.isArray(scope) ? this._service.splitScope(scope) : scope
|
||||
).join(' ');
|
||||
|
||||
const expiresAt = new Date(Date.now() + 3.154e7 * 1000);
|
||||
|
||||
this._service.tokenService.insertToken(
|
||||
accessToken,
|
||||
OAuth2TokenType.REFRESH_TOKEN,
|
||||
client,
|
||||
scopes,
|
||||
expiresAt,
|
||||
user,
|
||||
);
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
async fetchByToken(
|
||||
token: string | OAuth2RefreshToken,
|
||||
): Promise<OAuth2RefreshToken> {
|
||||
const findBy = typeof token === 'string' ? token : token.token;
|
||||
const find = await this._service.tokenService.fetchByToken(
|
||||
findBy,
|
||||
OAuth2TokenType.REFRESH_TOKEN,
|
||||
);
|
||||
|
||||
return {
|
||||
...find,
|
||||
client_id: find.client.client_id,
|
||||
user_id: find.user.id,
|
||||
};
|
||||
}
|
||||
|
||||
async removeByUserIdClientId(
|
||||
userId: number,
|
||||
clientId: string,
|
||||
): Promise<boolean> {
|
||||
const find = await this._service.tokenService.fetchByUserIdClientId(
|
||||
userId,
|
||||
clientId,
|
||||
OAuth2TokenType.REFRESH_TOKEN,
|
||||
);
|
||||
|
||||
await this._service.tokenService.remove(find);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeByRefreshToken(token: string): Promise<boolean> {
|
||||
const find = await this._service.tokenService.fetchByToken(
|
||||
token,
|
||||
OAuth2TokenType.REFRESH_TOKEN,
|
||||
);
|
||||
|
||||
await this._service.tokenService.remove(find);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getUserId(code: OAuth2RefreshToken): number {
|
||||
return code.user_id as number;
|
||||
}
|
||||
|
||||
getClientId(code: OAuth2RefreshToken): string {
|
||||
return code.client_id as string;
|
||||
}
|
||||
|
||||
getScope(code: OAuth2RefreshToken): string {
|
||||
return code.scope;
|
||||
}
|
||||
}
|
57
src/modules/features/oauth2/adapter/user.adapter.ts
Normal file
57
src/modules/features/oauth2/adapter/user.adapter.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { OAuth2UserAdapter, OAuth2User } from '@icynet/oauth2-provider';
|
||||
import { OAuth2Service } from '../oauth2.service';
|
||||
import { Request } from 'express';
|
||||
import { ParamsDictionary } from 'express-serve-static-core';
|
||||
import { ParsedQs } from 'qs';
|
||||
|
||||
export class UserAdapter implements OAuth2UserAdapter {
|
||||
constructor(private _service: OAuth2Service) {}
|
||||
|
||||
getId(user: OAuth2User): number {
|
||||
return user.id as number;
|
||||
}
|
||||
|
||||
async fetchById(id: number): Promise<OAuth2User> {
|
||||
const find = await this._service.userService.getById(id);
|
||||
return {
|
||||
id: find.id,
|
||||
username: find.username,
|
||||
password: find.password,
|
||||
};
|
||||
}
|
||||
|
||||
async fetchByUsername(username: string): Promise<OAuth2User> {
|
||||
const find = await this._service.userService.getByUsername(username);
|
||||
return {
|
||||
id: find.id,
|
||||
username: find.username,
|
||||
password: find.password,
|
||||
};
|
||||
}
|
||||
|
||||
checkPassword(user: OAuth2User, password: string): Promise<boolean> {
|
||||
return this._service.userService.comparePasswords(user.password, password);
|
||||
}
|
||||
|
||||
async fetchFromRequest(
|
||||
req: Request<ParamsDictionary, any, any, ParsedQs, Record<string, any>>,
|
||||
): Promise<OAuth2User> {
|
||||
return req.session.user;
|
||||
}
|
||||
|
||||
async consented(
|
||||
userId: number,
|
||||
clientId: string,
|
||||
scope: string | string[],
|
||||
): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async consent(
|
||||
userId: number,
|
||||
clientId: string,
|
||||
scope: string | string[],
|
||||
): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
46
src/modules/features/oauth2/oauth2.controller.ts
Normal file
46
src/modules/features/oauth2/oauth2.controller.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { Controller, Get, Next, Post, Req, Res } from '@nestjs/common';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { OAuth2Service } from './oauth2.service';
|
||||
|
||||
@Controller('oauth2')
|
||||
export class OAuth2Controller {
|
||||
constructor(private _service: OAuth2Service) {}
|
||||
|
||||
// These requests are just passed straight on to the provider controller
|
||||
|
||||
@Get('authorize')
|
||||
public authorizeGetWrapper(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
): void {
|
||||
return this._service.oauth.controller.authorization(req, res, next);
|
||||
}
|
||||
|
||||
@Post('authorize')
|
||||
public authorizePostWrapper(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
): void {
|
||||
return this._service.oauth.controller.authorization(req, res, next);
|
||||
}
|
||||
|
||||
@Post('token')
|
||||
public tokenWrapper(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
): void {
|
||||
return this._service.oauth.controller.token(req, res, next);
|
||||
}
|
||||
|
||||
@Post('introspect')
|
||||
public introspectWrapper(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
): void {
|
||||
return this._service.oauth.controller.introspection(req, res, next);
|
||||
}
|
||||
}
|
23
src/modules/features/oauth2/oauth2.module.ts
Normal file
23
src/modules/features/oauth2/oauth2.module.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { AuthMiddleware } from 'src/middleware/auth.middleware';
|
||||
import { OAuth2ClientModule } from 'src/modules/objects/oauth2-client/oauth2-client.module';
|
||||
import { OAuth2TokenModule } from 'src/modules/objects/oauth2-token/oauth2-token.module';
|
||||
import { UploadModule } from 'src/modules/objects/upload/upload.module';
|
||||
import { UserModule } from 'src/modules/objects/user/user.module';
|
||||
import { OAuth2Controller } from './oauth2.controller';
|
||||
import { OAuth2Service } from './oauth2.service';
|
||||
|
||||
@Module({
|
||||
imports: [UserModule, UploadModule, OAuth2ClientModule, OAuth2TokenModule],
|
||||
controllers: [OAuth2Controller],
|
||||
providers: [OAuth2Service],
|
||||
exports: [OAuth2Service],
|
||||
})
|
||||
export class OAuth2Module implements NestModule {
|
||||
constructor(private _service: OAuth2Service) {}
|
||||
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(this._service.oauth.express()).forRoutes('oauth2/*');
|
||||
consumer.apply(AuthMiddleware).forRoutes('oauth2/authorize');
|
||||
}
|
||||
}
|
43
src/modules/features/oauth2/oauth2.service.ts
Normal file
43
src/modules/features/oauth2/oauth2.service.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { OAuth2AdapterModel, OAuth2Provider } from '@icynet/oauth2-provider';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service';
|
||||
import { OAuth2TokenService } from 'src/modules/objects/oauth2-token/oauth2-token.service';
|
||||
import { UserService } from 'src/modules/objects/user/user.service';
|
||||
import { TokenService } from 'src/modules/utility/services/token.service';
|
||||
import { AccessTokenAdapter } from './adapter/access-token.adapter';
|
||||
import { ClientAdapter } from './adapter/client.adapter';
|
||||
import { CodeAdapter } from './adapter/code.adapter';
|
||||
import { RefreshTokenAdapter } from './adapter/refresh-token.adapter';
|
||||
import { UserAdapter } from './adapter/user.adapter';
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2Service {
|
||||
private _oauthAdapter: OAuth2AdapterModel = {
|
||||
accessToken: new AccessTokenAdapter(this),
|
||||
refreshToken: new RefreshTokenAdapter(this),
|
||||
user: new UserAdapter(this),
|
||||
client: new ClientAdapter(this),
|
||||
code: new CodeAdapter(this),
|
||||
};
|
||||
|
||||
public oauth = new OAuth2Provider(this._oauthAdapter, async (req, res) => {
|
||||
res.render('authorize');
|
||||
});
|
||||
|
||||
constructor(
|
||||
public token: TokenService,
|
||||
public userService: UserService,
|
||||
public clientService: OAuth2ClientService,
|
||||
public tokenService: OAuth2TokenService,
|
||||
) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
this.oauth.logger.setLogLevel('debug');
|
||||
}
|
||||
}
|
||||
|
||||
public splitScope(scope: string): string[] {
|
||||
return scope.includes(',')
|
||||
? scope.split(',').map((item) => item.trim())
|
||||
: scope.split(' ');
|
||||
}
|
||||
}
|
97
src/modules/features/register/register.controller.ts
Normal file
97
src/modules/features/register/register.controller.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
Render,
|
||||
Req,
|
||||
Res,
|
||||
Session,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { SessionData } from 'express-session';
|
||||
import { UserService } from 'src/modules/objects/user/user.service';
|
||||
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
|
||||
import { RegisterDto } from './register.interfaces';
|
||||
|
||||
@Controller('/register')
|
||||
export class RegisterController {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly formUtil: FormUtilityService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Render('register')
|
||||
public registerView(
|
||||
@Session() session: SessionData,
|
||||
@Req() req: Request,
|
||||
): Record<string, any> {
|
||||
return this.formUtil.populateTemplate(req, session);
|
||||
}
|
||||
|
||||
@Post()
|
||||
public async registerRequest(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Body() body: RegisterDto,
|
||||
@Query() query: { redirectTo?: string },
|
||||
) {
|
||||
const { username, display_name, email, password, password_repeat } = body;
|
||||
|
||||
try {
|
||||
if (
|
||||
!username ||
|
||||
!display_name ||
|
||||
!email ||
|
||||
!password ||
|
||||
!password_repeat
|
||||
) {
|
||||
throw new Error('Please fill out all of the fields!');
|
||||
}
|
||||
|
||||
if (!username || !username.match(this.formUtil.usernameRegex)) {
|
||||
throw new Error(
|
||||
'Username must be alphanumeric and between 3 to 26 characters long (_, - and . are also allowed)',
|
||||
);
|
||||
}
|
||||
|
||||
if (display_name.length < 3 || display_name.length > 32) {
|
||||
throw new Error(
|
||||
'Display name must be between 3 and 32 characters long.',
|
||||
);
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/21456918
|
||||
if (!password.match(this.formUtil.passwordRegex)) {
|
||||
throw new Error(
|
||||
'Password must be at least 8 characters long, contain a capital and lowercase letter and a number',
|
||||
);
|
||||
}
|
||||
|
||||
if (!email.match(this.formUtil.emailRegex)) {
|
||||
throw new Error('Invalid email address!');
|
||||
}
|
||||
|
||||
if (password !== password_repeat) {
|
||||
throw new Error('The passwords do not match!');
|
||||
}
|
||||
|
||||
await this.userService.userRegistration(body);
|
||||
|
||||
req.flash('message', {
|
||||
error: false,
|
||||
text: `An activation email has been sent to ${email}!`,
|
||||
});
|
||||
|
||||
res.redirect(
|
||||
'/login' + (query.redirectTo ? '?redirectTo=' + query.redirectTo : ''),
|
||||
);
|
||||
} catch (e: any) {
|
||||
req.flash('message', { error: true, text: e.message });
|
||||
req.flash('form', { ...body, password_repeat: undefined });
|
||||
res.redirect(req.originalUrl);
|
||||
}
|
||||
}
|
||||
}
|
7
src/modules/features/register/register.interfaces.ts
Normal file
7
src/modules/features/register/register.interfaces.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface RegisterDto {
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password_repeat: string;
|
||||
}
|
21
src/modules/features/register/register.module.ts
Normal file
21
src/modules/features/register/register.module.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import {
|
||||
MiddlewareConsumer,
|
||||
Module,
|
||||
NestModule,
|
||||
RequestMethod,
|
||||
} from '@nestjs/common';
|
||||
import { ValidateCSRFMiddleware } from 'src/middleware/validate-csrf.middleware';
|
||||
import { UserModule } from 'src/modules/objects/user/user.module';
|
||||
import { RegisterController } from './register.controller';
|
||||
|
||||
@Module({
|
||||
imports: [UserModule],
|
||||
controllers: [RegisterController],
|
||||
})
|
||||
export class RegisterModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer
|
||||
.apply(ValidateCSRFMiddleware)
|
||||
.forRoutes({ path: '*', method: RequestMethod.POST });
|
||||
}
|
||||
}
|
8
src/modules/objects/database/database.module.ts
Normal file
8
src/modules/objects/database/database.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { databaseProviders } from './database.providers';
|
||||
|
||||
@Module({
|
||||
providers: [...databaseProviders],
|
||||
exports: [...databaseProviders],
|
||||
})
|
||||
export class DatabaseModule {}
|
18
src/modules/objects/database/database.providers.ts
Normal file
18
src/modules/objects/database/database.providers.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { 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,
|
||||
}),
|
||||
},
|
||||
];
|
@ -0,0 +1,33 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../user/user.entity';
|
||||
import { OAuth2Client } from './oauth2-client.entity';
|
||||
|
||||
@Entity()
|
||||
export class OAuth2ClientAuthorization {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
public scope: string;
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
public expires_at: Date;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'timestamp',
|
||||
default: () => 'CURRENT_TIMESTAMP(6)',
|
||||
})
|
||||
public created_at: Date;
|
||||
|
||||
@ManyToOne(() => OAuth2Client)
|
||||
public client: OAuth2Client;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
public user: User;
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { OAuth2Client } from './oauth2-client.entity';
|
||||
|
||||
export enum OAuth2ClientURLType {
|
||||
REDIRECT_URI = 'redirect_uri',
|
||||
TERMS = 'terms',
|
||||
PRIVACY = 'privacy',
|
||||
WEBSITE = 'website',
|
||||
}
|
||||
|
||||
@Entity()
|
||||
export class OAuth2ClientURL {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@Column({ nullable: false })
|
||||
public url: string;
|
||||
|
||||
@Column({ type: 'enum', enum: OAuth2ClientURLType, nullable: false })
|
||||
public type: OAuth2ClientURLType;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'timestamp',
|
||||
default: () => 'CURRENT_TIMESTAMP(6)',
|
||||
})
|
||||
public created_at: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
type: 'timestamp',
|
||||
default: () => 'CURRENT_TIMESTAMP(6)',
|
||||
onUpdate: 'CURRENT_TIMESTAMP(6)',
|
||||
})
|
||||
public updated_at: Date;
|
||||
|
||||
@ManyToOne(() => OAuth2Client, (client) => client.urls)
|
||||
public client: OAuth2Client;
|
||||
}
|
64
src/modules/objects/oauth2-client/oauth2-client.entity.ts
Normal file
64
src/modules/objects/oauth2-client/oauth2-client.entity.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Upload } from '../upload/upload.entity';
|
||||
import { User } from '../user/user.entity';
|
||||
import { OAuth2ClientURL } from './oauth2-client-url.entity';
|
||||
|
||||
@Entity()
|
||||
export class OAuth2Client {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'uuid', length: 36, nullable: false, unique: true })
|
||||
client_id: string;
|
||||
|
||||
@Column({ type: 'text', nullable: false })
|
||||
client_secret: string;
|
||||
|
||||
@Column({ nullable: false })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
scope: string;
|
||||
|
||||
@Column({ type: 'text', default: 'authorization_code' })
|
||||
grants: string;
|
||||
|
||||
@Column({ default: false })
|
||||
activated: boolean;
|
||||
|
||||
@Column({ default: false })
|
||||
verified: boolean;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'timestamp',
|
||||
default: () => 'CURRENT_TIMESTAMP(6)',
|
||||
})
|
||||
public created_at: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
type: 'timestamp',
|
||||
default: () => 'CURRENT_TIMESTAMP(6)',
|
||||
onUpdate: 'CURRENT_TIMESTAMP(6)',
|
||||
})
|
||||
public updated_at: Date;
|
||||
|
||||
@OneToOne(() => Upload, { nullable: true, onDelete: 'SET NULL' })
|
||||
public picture: Upload;
|
||||
|
||||
@OneToOne(() => User, { nullable: true, onDelete: 'SET NULL' })
|
||||
public owner: User;
|
||||
|
||||
@OneToMany(() => OAuth2ClientURL, (url) => url.client)
|
||||
public urls: OAuth2ClientURL[];
|
||||
}
|
11
src/modules/objects/oauth2-client/oauth2-client.module.ts
Normal file
11
src/modules/objects/oauth2-client/oauth2-client.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { clientProviders } from './oauth2-client.providers';
|
||||
import { OAuth2ClientService } from './oauth2-client.service';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
providers: [...clientProviders, OAuth2ClientService],
|
||||
exports: [OAuth2ClientService],
|
||||
})
|
||||
export class OAuth2ClientModule {}
|
25
src/modules/objects/oauth2-client/oauth2-client.providers.ts
Normal file
25
src/modules/objects/oauth2-client/oauth2-client.providers.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Connection } from 'typeorm';
|
||||
import { OAuth2ClientAuthorization } from './oauth2-client-authorization.entity';
|
||||
import { OAuth2ClientURL } from './oauth2-client-url.entity';
|
||||
import { OAuth2Client } from './oauth2-client.entity';
|
||||
|
||||
export const clientProviders = [
|
||||
{
|
||||
provide: 'CLIENT_REPOSITORY',
|
||||
useFactory: (connection: Connection) =>
|
||||
connection.getRepository(OAuth2Client),
|
||||
inject: ['DATABASE_CONNECTION'],
|
||||
},
|
||||
{
|
||||
provide: 'CLIENT_URL_REPOSITORY',
|
||||
useFactory: (connection: Connection) =>
|
||||
connection.getRepository(OAuth2ClientURL),
|
||||
inject: ['DATABASE_CONNECTION'],
|
||||
},
|
||||
{
|
||||
provide: 'CLIENT_AUTHORIZATION_REPOSITORY',
|
||||
useFactory: (connection: Connection) =>
|
||||
connection.getRepository(OAuth2ClientAuthorization),
|
||||
inject: ['DATABASE_CONNECTION'],
|
||||
},
|
||||
];
|
47
src/modules/objects/oauth2-client/oauth2-client.service.ts
Normal file
47
src/modules/objects/oauth2-client/oauth2-client.service.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { OAuth2ClientAuthorization } from './oauth2-client-authorization.entity';
|
||||
import {
|
||||
OAuth2ClientURL,
|
||||
OAuth2ClientURLType,
|
||||
} from './oauth2-client-url.entity';
|
||||
import { OAuth2Client } from './oauth2-client.entity';
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2ClientService {
|
||||
constructor(
|
||||
@Inject('CLIENT_REPOSITORY')
|
||||
private clientRepository: Repository<OAuth2Client>,
|
||||
@Inject('CLIENT_URL_REPOSITORY')
|
||||
private clientUrlRepository: Repository<OAuth2ClientURL>,
|
||||
@Inject('CLIENT_AUTHORIZATION_REPOSITORY')
|
||||
private clientAuthRepository: Repository<OAuth2ClientAuthorization>,
|
||||
) {}
|
||||
|
||||
public async getById(id: string | number): Promise<OAuth2Client> {
|
||||
let client: OAuth2Client;
|
||||
|
||||
if (typeof id === 'string') {
|
||||
client = await this.clientRepository.findOne({ client_id: id });
|
||||
} else {
|
||||
client = await this.clientRepository.findOne({ id });
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
public async getClientURLs(
|
||||
id: string,
|
||||
type?: OAuth2ClientURLType,
|
||||
): Promise<OAuth2ClientURL[]> {
|
||||
return this.clientUrlRepository.find({ client: { client_id: id }, type });
|
||||
}
|
||||
|
||||
public async checkRedirectURI(id: string, url: string): Promise<boolean> {
|
||||
return !!(await this.clientUrlRepository.findOne({
|
||||
client: { client_id: id },
|
||||
url,
|
||||
type: OAuth2ClientURLType.REDIRECT_URI,
|
||||
}));
|
||||
}
|
||||
}
|
53
src/modules/objects/oauth2-token/oauth2-token.entity.ts
Normal file
53
src/modules/objects/oauth2-token/oauth2-token.entity.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { OAuth2Client } from '../oauth2-client/oauth2-client.entity';
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
export enum OAuth2TokenType {
|
||||
CODE = 'code',
|
||||
ACCESS_TOKEN = 'access_token',
|
||||
REFRESH_TOKEN = 'refresh_token',
|
||||
}
|
||||
|
||||
@Entity()
|
||||
export class OAuth2Token {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'enum', enum: OAuth2TokenType, nullable: false })
|
||||
type: OAuth2TokenType;
|
||||
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
token: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
scope: string;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
user: User;
|
||||
|
||||
@ManyToOne(() => OAuth2Client)
|
||||
client: OAuth2Client;
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
public expires_at: Date;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'timestamp',
|
||||
default: () => 'CURRENT_TIMESTAMP(6)',
|
||||
})
|
||||
public created_at: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
type: 'timestamp',
|
||||
default: () => 'CURRENT_TIMESTAMP(6)',
|
||||
onUpdate: 'CURRENT_TIMESTAMP(6)',
|
||||
})
|
||||
public updated_at: Date;
|
||||
}
|
11
src/modules/objects/oauth2-token/oauth2-token.module.ts
Normal file
11
src/modules/objects/oauth2-token/oauth2-token.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { tokenProviders } from './oauth2-token.providers';
|
||||
import { OAuth2TokenService } from './oauth2-token.service';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
providers: [...tokenProviders, OAuth2TokenService],
|
||||
exports: [OAuth2TokenService],
|
||||
})
|
||||
export class OAuth2TokenModule {}
|
11
src/modules/objects/oauth2-token/oauth2-token.providers.ts
Normal file
11
src/modules/objects/oauth2-token/oauth2-token.providers.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Connection } from 'typeorm';
|
||||
import { OAuth2Token } from './oauth2-token.entity';
|
||||
|
||||
export const tokenProviders = [
|
||||
{
|
||||
provide: 'TOKEN_REPOSITORY',
|
||||
useFactory: (connection: Connection) =>
|
||||
connection.getRepository(OAuth2Token),
|
||||
inject: ['DATABASE_CONNECTION'],
|
||||
},
|
||||
];
|
66
src/modules/objects/oauth2-token/oauth2-token.service.ts
Normal file
66
src/modules/objects/oauth2-token/oauth2-token.service.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { OAuth2Client } from '../oauth2-client/oauth2-client.entity';
|
||||
import { User } from '../user/user.entity';
|
||||
import { OAuth2Token, OAuth2TokenType } from './oauth2-token.entity';
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2TokenService {
|
||||
constructor(
|
||||
@Inject('TOKEN_REPOSITORY')
|
||||
private tokenRepository: Repository<OAuth2Token>,
|
||||
) {}
|
||||
|
||||
public async insertToken(
|
||||
token: string,
|
||||
type: OAuth2TokenType,
|
||||
client: OAuth2Client,
|
||||
scope: string,
|
||||
expiry: Date,
|
||||
user?: User,
|
||||
): Promise<OAuth2Token> {
|
||||
const newToken = new OAuth2Token();
|
||||
newToken.client = client;
|
||||
newToken.token = token;
|
||||
newToken.type = type;
|
||||
newToken.scope = scope;
|
||||
newToken.user = user;
|
||||
newToken.expires_at = expiry;
|
||||
|
||||
await this.tokenRepository.save(newToken);
|
||||
|
||||
return newToken;
|
||||
}
|
||||
|
||||
public async fetchByToken(
|
||||
token: string,
|
||||
type: OAuth2TokenType,
|
||||
): Promise<OAuth2Token> {
|
||||
return this.tokenRepository.findOne({
|
||||
token,
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
public async fetchByUserIdClientId(
|
||||
userId: number,
|
||||
clientId: string,
|
||||
type: OAuth2TokenType,
|
||||
): Promise<OAuth2Token> {
|
||||
return this.tokenRepository.findOne({
|
||||
where: {
|
||||
user: {
|
||||
id: userId,
|
||||
},
|
||||
client: {
|
||||
client_id: clientId,
|
||||
},
|
||||
type,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async remove(token: OAuth2Token): Promise<void> {
|
||||
await this.tokenRepository.remove(token);
|
||||
}
|
||||
}
|
41
src/modules/objects/upload/upload.entity.ts
Normal file
41
src/modules/objects/upload/upload.entity.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
@Entity()
|
||||
export class Upload {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ nullable: false })
|
||||
original_name: string;
|
||||
|
||||
@Column({ nullable: false })
|
||||
file: string;
|
||||
|
||||
@ManyToOne(() => User, {
|
||||
nullable: true,
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE',
|
||||
})
|
||||
uploader: User;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'timestamp',
|
||||
default: () => 'CURRENT_TIMESTAMP(6)',
|
||||
})
|
||||
public created_at: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
type: 'timestamp',
|
||||
default: () => 'CURRENT_TIMESTAMP(6)',
|
||||
onUpdate: 'CURRENT_TIMESTAMP(6)',
|
||||
})
|
||||
public updated_at: Date;
|
||||
}
|
11
src/modules/objects/upload/upload.module.ts
Normal file
11
src/modules/objects/upload/upload.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { uploadProviders } from './upload.providers';
|
||||
import { UploadService } from './upload.service';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
providers: [...uploadProviders, UploadService],
|
||||
exports: [UploadService],
|
||||
})
|
||||
export class UploadModule {}
|
10
src/modules/objects/upload/upload.providers.ts
Normal file
10
src/modules/objects/upload/upload.providers.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Connection } from 'typeorm';
|
||||
import { Upload } from './upload.entity';
|
||||
|
||||
export const uploadProviders = [
|
||||
{
|
||||
provide: 'UPLOAD_REPOSITORY',
|
||||
useFactory: (connection: Connection) => connection.getRepository(Upload),
|
||||
inject: ['DATABASE_CONNECTION'],
|
||||
},
|
||||
];
|
11
src/modules/objects/upload/upload.service.ts
Normal file
11
src/modules/objects/upload/upload.service.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Upload } from './upload.entity';
|
||||
|
||||
@Injectable()
|
||||
export class UploadService {
|
||||
constructor(
|
||||
@Inject('UPLOAD_REPOSITORY')
|
||||
private uploadRepository: Repository<Upload>,
|
||||
) {}
|
||||
}
|
41
src/modules/objects/user/user-token.entity.ts
Normal file
41
src/modules/objects/user/user-token.entity.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
|
||||
export enum UserTokenType {
|
||||
GENERIC = 'generic',
|
||||
ACTIVATION = 'activation',
|
||||
DEACTIVATION = 'deactivation',
|
||||
PASSWORD = 'password',
|
||||
LOGIN = 'login',
|
||||
GDPR = 'gdpr',
|
||||
}
|
||||
|
||||
@Entity()
|
||||
export class UserToken {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
token: string;
|
||||
|
||||
@Column({ type: 'enum', enum: UserTokenType, nullable: false })
|
||||
type: UserTokenType;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
public expires_at: Date;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'timestamp',
|
||||
default: () => 'CURRENT_TIMESTAMP(6)',
|
||||
})
|
||||
public created_at: Date;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
user: User;
|
||||
}
|
35
src/modules/objects/user/user-totp-token.entity.ts
Normal file
35
src/modules/objects/user/user-totp-token.entity.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Entity()
|
||||
export class UserTOTPToken {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
token: string;
|
||||
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
recovery_token: string;
|
||||
|
||||
@Column({ default: false, nullable: false })
|
||||
activated: boolean;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
public expires_at: Date;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'timestamp',
|
||||
default: () => 'CURRENT_TIMESTAMP(6)',
|
||||
})
|
||||
public created_at: Date;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
user: User;
|
||||
}
|
56
src/modules/objects/user/user.entity.ts
Normal file
56
src/modules/objects/user/user.entity.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Upload } from '../upload/upload.entity';
|
||||
|
||||
@Entity()
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'uuid', length: 36, nullable: false, unique: true })
|
||||
uuid: string;
|
||||
|
||||
@Column({ length: 26, nullable: false, unique: true })
|
||||
username: string;
|
||||
|
||||
@Column({ nullable: false, unique: true })
|
||||
email: string;
|
||||
|
||||
@Column({ length: 32, nullable: false })
|
||||
display_name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
password: string;
|
||||
|
||||
@Column({ default: false })
|
||||
activated: boolean;
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
public activity_at: Date;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'timestamp',
|
||||
default: () => 'CURRENT_TIMESTAMP(6)',
|
||||
})
|
||||
public created_at: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
type: 'timestamp',
|
||||
default: () => 'CURRENT_TIMESTAMP(6)',
|
||||
onUpdate: 'CURRENT_TIMESTAMP(6)',
|
||||
})
|
||||
public updated_at: Date;
|
||||
|
||||
@OneToOne(() => Upload, {
|
||||
nullable: true,
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE',
|
||||
})
|
||||
public picture: Upload;
|
||||
}
|
11
src/modules/objects/user/user.module.ts
Normal file
11
src/modules/objects/user/user.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { userProviders } from './user.providers';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
providers: [...userProviders, UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
23
src/modules/objects/user/user.providers.ts
Normal file
23
src/modules/objects/user/user.providers.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Connection } from 'typeorm';
|
||||
import { UserToken } from './user-token.entity';
|
||||
import { UserTOTPToken } from './user-totp-token.entity';
|
||||
import { User } from './user.entity';
|
||||
|
||||
export const userProviders = [
|
||||
{
|
||||
provide: 'USER_REPOSITORY',
|
||||
useFactory: (connection: Connection) => connection.getRepository(User),
|
||||
inject: ['DATABASE_CONNECTION'],
|
||||
},
|
||||
{
|
||||
provide: 'USER_TOKEN_REPOSITORY',
|
||||
useFactory: (connection: Connection) => connection.getRepository(UserToken),
|
||||
inject: ['DATABASE_CONNECTION'],
|
||||
},
|
||||
{
|
||||
provide: 'USER_TOTP_TOKEN_REPOSITORY',
|
||||
useFactory: (connection: Connection) =>
|
||||
connection.getRepository(UserTOTPToken),
|
||||
inject: ['DATABASE_CONNECTION'],
|
||||
},
|
||||
];
|
146
src/modules/objects/user/user.service.ts
Normal file
146
src/modules/objects/user/user.service.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserToken, UserTokenType } from './user-token.entity';
|
||||
import { UserTOTPToken } from './user-totp-token.entity';
|
||||
import { User } from './user.entity';
|
||||
import speakeasy from '@levminer/speakeasy';
|
||||
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { TokenService } from 'src/modules/utility/services/token.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@Inject('USER_REPOSITORY')
|
||||
private userRepository: Repository<User>,
|
||||
@Inject('USER_TOKEN_REPOSITORY')
|
||||
private userTokenRepository: Repository<UserToken>,
|
||||
@Inject('USER_TOTP_TOKEN_REPOSITORY')
|
||||
private userTOTPTokenRepository: Repository<UserTOTPToken>,
|
||||
private token: TokenService,
|
||||
) {}
|
||||
|
||||
public async getById(id: number): Promise<User> {
|
||||
return this.userRepository.findOne({ id });
|
||||
}
|
||||
|
||||
public async getByUUID(uuid: string): Promise<User> {
|
||||
return this.userRepository.findOne({ uuid });
|
||||
}
|
||||
|
||||
public async getByEmail(email: string): Promise<User> {
|
||||
return this.userRepository.findOne({ email });
|
||||
}
|
||||
|
||||
public async getByUsername(username: string): Promise<User> {
|
||||
return this.userRepository.findOne({ username });
|
||||
}
|
||||
|
||||
public async get(input: string | number): Promise<User> {
|
||||
if (typeof input === 'number') {
|
||||
return this.getById(input);
|
||||
}
|
||||
|
||||
if (input.includes('@')) {
|
||||
return this.getByEmail(input);
|
||||
}
|
||||
|
||||
if (input.length === 36 && input.includes('-')) {
|
||||
return this.getByUUID(input);
|
||||
}
|
||||
|
||||
return this.getByUsername(input);
|
||||
}
|
||||
|
||||
public async comparePasswords(
|
||||
hash: string,
|
||||
password: string,
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
public async hashPassword(password: string): Promise<string> {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
return bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has TOTP enabled
|
||||
* @param user User object
|
||||
* @returns true if the user has TOTP enabled
|
||||
*/
|
||||
public async userHasTOTP(user: User): Promise<boolean> {
|
||||
return !!(await this.getUserTOTP(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the TOTP token of a user
|
||||
* @param user User object
|
||||
* @returns TOTP token
|
||||
*/
|
||||
public async getUserTOTP(user: User): Promise<UserTOTPToken> {
|
||||
return this.userTOTPTokenRepository.findOne({
|
||||
user,
|
||||
activated: true,
|
||||
});
|
||||
}
|
||||
|
||||
public validateTOTP(secret: string, token: string): boolean {
|
||||
return speakeasy.totp.verify({
|
||||
secret,
|
||||
encoding: 'base32',
|
||||
token,
|
||||
window: 6,
|
||||
});
|
||||
}
|
||||
|
||||
public async createUserToken(
|
||||
user: User,
|
||||
type: UserTokenType,
|
||||
expiresAt?: Date,
|
||||
): Promise<UserToken> {
|
||||
const token = new UserToken();
|
||||
token.token = this.token.generateString(64);
|
||||
token.user = user;
|
||||
token.type = type;
|
||||
token.expires_at = expiresAt;
|
||||
await this.userTokenRepository.save(token);
|
||||
return token;
|
||||
}
|
||||
|
||||
public async sendActivationEmail(user: User): Promise<void> {
|
||||
const activationToken = await this.createUserToken(
|
||||
user,
|
||||
UserTokenType.ACTIVATION,
|
||||
new Date(Date.now() + 3600 * 1000),
|
||||
);
|
||||
}
|
||||
|
||||
public async userRegistration(newUserInfo: {
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}): Promise<User> {
|
||||
if (!!(await this.getByEmail(newUserInfo.email))) {
|
||||
throw new Error('Email is already in use!');
|
||||
}
|
||||
|
||||
if (!!(await this.getByUsername(newUserInfo.username))) {
|
||||
throw new Error('Username is already in use!');
|
||||
}
|
||||
|
||||
const hashword = await this.hashPassword(newUserInfo.password);
|
||||
const user = new User();
|
||||
user.email = newUserInfo.email;
|
||||
user.uuid = this.token.createUUID();
|
||||
user.username = newUserInfo.username;
|
||||
user.display_name = newUserInfo.display_name;
|
||||
user.password = hashword;
|
||||
await this.userRepository.insert(user);
|
||||
|
||||
// TODO: activation email
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
35
src/modules/utility/services/form-utility.service.ts
Normal file
35
src/modules/utility/services/form-utility.service.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { SessionData } from 'express-session';
|
||||
|
||||
@Injectable()
|
||||
export class FormUtilityService {
|
||||
public emailRegex =
|
||||
/^[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/;
|
||||
public passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/;
|
||||
public usernameRegex = /^[a-zA-Z0-9_\-.]{3,26}$/;
|
||||
|
||||
public mergeObjectArray(flash: Record<string, any>[]): Record<string, any> {
|
||||
return flash.reduce<Record<string, any>>(
|
||||
(obj, item) => ({ ...obj, ...item }),
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Include CSRF token, messages and prefilled form values for a template with a form
|
||||
* @param req Express request
|
||||
* @param session Express session
|
||||
* @returns Template locals
|
||||
*/
|
||||
public populateTemplate(
|
||||
req: Request,
|
||||
session: SessionData,
|
||||
): 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 };
|
||||
}
|
||||
}
|
72
src/modules/utility/services/token.service.ts
Normal file
72
src/modules/utility/services/token.service.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as crypto from 'crypto';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
const IV_LENGTH = 16;
|
||||
const ALGORITHM = 'aes-256-cbc';
|
||||
|
||||
@Injectable()
|
||||
export class TokenService {
|
||||
public generateString(length: number): string {
|
||||
return crypto.randomBytes(length).toString('hex').slice(0, length);
|
||||
}
|
||||
|
||||
public createUUID(): string {
|
||||
return v4();
|
||||
}
|
||||
|
||||
/**
|
||||
* Symmetric encryption function
|
||||
* @param text String to encrypt
|
||||
* @param key Encryption key
|
||||
* @returns Encrypted text
|
||||
*/
|
||||
public encrypt(text: string, key: string): string {
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(
|
||||
ALGORITHM,
|
||||
Buffer.from(key, 'hex'),
|
||||
iv,
|
||||
);
|
||||
let encrypted = cipher.update(text);
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symmetric decryption function
|
||||
* @param text Encrypted string
|
||||
* @param key Decryption key
|
||||
* @returns Decrypted text
|
||||
*/
|
||||
public decrypt(text: string, key: string): string {
|
||||
const [iv, encryptedText] = text
|
||||
.split(':')
|
||||
.map((part) => Buffer.from(part, 'hex'));
|
||||
|
||||
const decipher = crypto.createDecipheriv(
|
||||
ALGORITHM,
|
||||
Buffer.from(key, 'hex'),
|
||||
iv,
|
||||
);
|
||||
|
||||
let decrypted = decipher.update(encryptedText);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
return decrypted.toString();
|
||||
}
|
||||
|
||||
public async encryptChallenge(
|
||||
challenge: Record<string, any>,
|
||||
): Promise<string> {
|
||||
return this.encrypt(
|
||||
JSON.stringify(challenge),
|
||||
process.env.CHALLENGE_SECRET,
|
||||
);
|
||||
}
|
||||
|
||||
public async decryptChallenge(
|
||||
challenge: string,
|
||||
): Promise<Record<string, any>> {
|
||||
return JSON.parse(this.decrypt(challenge, process.env.CHALLENGE_SECRET));
|
||||
}
|
||||
}
|
10
src/modules/utility/utility.module.ts
Normal file
10
src/modules/utility/utility.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { FormUtilityService } from './services/form-utility.service';
|
||||
import { TokenService } from './services/token.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [TokenService, FormUtilityService],
|
||||
exports: [TokenService, FormUtilityService],
|
||||
})
|
||||
export class UtilityModule {}
|
18
src/scss/_index.scss
Normal file
18
src/scss/_index.scss
Normal file
@ -0,0 +1,18 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
20
src/types/express-session.d.ts
vendored
Normal file
20
src/types/express-session.d.ts
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
import { OAuth2 } from '@icynet/oauth2-provider';
|
||||
import { User } from 'src/modules/user/user.entity';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
export interface Request {
|
||||
oauth2: OAuth2;
|
||||
flash: (type: string, ...msg: any[]) => Record<string, any>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
csrf?: string;
|
||||
user?: User;
|
||||
challenge?: string;
|
||||
flash?: Record<string, any>;
|
||||
}
|
||||
}
|
24
test/app.e2e-spec.ts
Normal file
24
test/app.e2e-spec.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
});
|
9
test/jest-e2e.json
Normal file
9
test/jest-e2e.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es2017",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"typeRoots": [
|
||||
"node_modules/@types",
|
||||
"src/types"
|
||||
],
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
7
views/authorize.pug
Normal file
7
views/authorize.pug
Normal file
@ -0,0 +1,7 @@
|
||||
extends partials/layout.pug
|
||||
|
||||
block title
|
||||
|Icy Network | Authorize
|
||||
|
||||
block body
|
||||
h1 Authorize
|
9
views/index.pug
Normal file
9
views/index.pug
Normal file
@ -0,0 +1,9 @@
|
||||
extends partials/layout.pug
|
||||
|
||||
block title
|
||||
|Icy Network
|
||||
|
||||
block body
|
||||
h1 Hello world!
|
||||
if user
|
||||
h2 Logged in as #{ user.display_name }
|
20
views/login.pug
Normal file
20
views/login.pug
Normal file
@ -0,0 +1,20 @@
|
||||
extends partials/layout.pug
|
||||
|
||||
block title
|
||||
|Icy Network | Log in
|
||||
|
||||
block body
|
||||
h1 Log in
|
||||
if message.text
|
||||
if message.error
|
||||
.alert.alert-danger
|
||||
span #{message.text}
|
||||
else
|
||||
.alert.alert-success
|
||||
span #{message.text}
|
||||
|
||||
form(method="post")
|
||||
input#csrf(type="hidden", name="csrf", value=csrf)
|
||||
input#username(type="text", name="username", placeholder="Username", value=form.username)
|
||||
input#password(type="password", name="password", placeholder="Password")
|
||||
button(type="submit") Log in
|
20
views/partials/layout.pug
Normal file
20
views/partials/layout.pug
Normal file
@ -0,0 +1,20 @@
|
||||
doctype html
|
||||
html(lang="en")
|
||||
head
|
||||
meta(charset="UTF-8")
|
||||
meta(http-equiv="X-UA-Compatible", content="IE=edge")
|
||||
meta(name="viewport", content="width=device-width, initial-scale=1.0")
|
||||
block meta
|
||||
meta(name="og:title", content="Icy Network")
|
||||
meta(name="og:description", content="Icy Network is a Single-Sign-On (SSO) provider")
|
||||
meta(name="og:image", content="https://icynet.eu/static/image/icynet-icon.png")
|
||||
meta(name="og:type", content="website")
|
||||
meta(name="twitter:card", content="summary")
|
||||
meta(name="twitter:title", content="Icy Network")
|
||||
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")
|
||||
title
|
||||
block title
|
||||
body
|
||||
block body
|
24
views/register.pug
Normal file
24
views/register.pug
Normal file
@ -0,0 +1,24 @@
|
||||
extends partials/layout.pug
|
||||
|
||||
block title
|
||||
|Icy Network | Register
|
||||
|
||||
block body
|
||||
h1 Register
|
||||
if message.text
|
||||
if message.error
|
||||
.alert.alert-danger
|
||||
span #{message.text}
|
||||
else
|
||||
.alert.alert-success
|
||||
span #{message.text}
|
||||
|
||||
form(method="post")
|
||||
input#csrf(type="hidden", name="csrf", value=csrf)
|
||||
input#username(type="text", name="username", placeholder="Username", value=form.username)
|
||||
input#display_name(type="text", name="display_name", placeholder="Display name", value=form.display_name)
|
||||
input#email(type="email", name="email", placeholder="Email address", value=form.email)
|
||||
input#password(type="password", name="password", placeholder="Password", value=form.password)
|
||||
input#password_repeat(type="password", name="password_repeat", placeholder="Confirm password")
|
||||
button(type="submit") Create a new account
|
||||
a(type="button" href="/login") Log in
|
19
views/totp-verify.pug
Normal file
19
views/totp-verify.pug
Normal file
@ -0,0 +1,19 @@
|
||||
extends partials/layout.pug
|
||||
|
||||
block title
|
||||
|Icy Network | Veify two-factor
|
||||
|
||||
block body
|
||||
h1 Verify two-factor authentication
|
||||
if message.text
|
||||
if message.error
|
||||
.alert.alert-danger
|
||||
span #{message.text}
|
||||
else
|
||||
.alert.alert-success
|
||||
span #{message.text}
|
||||
|
||||
form(method="post")
|
||||
input#csrf(type="hidden", name="csrf", value=csrf)
|
||||
input#totp(type="text", name="totp", placeholder="Code")
|
||||
button(type="submit") Log in
|
Reference in New Issue
Block a user