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