Initial service setup

This commit is contained in:
Evert Prants 2022-03-09 20:37:04 +02:00
commit 75a7bcfa48
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
67 changed files with 19756 additions and 0 deletions

25
.eslintrc.js Normal file
View 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
View 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
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

73
README.md Normal file
View 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
View File

@ -0,0 +1,4 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

17432
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

88
package.json Normal file
View 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
View 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}

View 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
View 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
View 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
View 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
View 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();

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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) : '/');
}
}

View 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 });
}
}

View 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,
};
}
}

View 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));
}
}

View 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();
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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');
}
}

View 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(' ');
}
}

View 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);
}
}
}

View File

@ -0,0 +1,7 @@
export interface RegisterDto {
username: string;
display_name: string;
email: string;
password: string;
password_repeat: string;
}

View 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 });
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { databaseProviders } from './database.providers';
@Module({
providers: [...databaseProviders],
exports: [...databaseProviders],
})
export class DatabaseModule {}

View 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,
}),
},
];

View File

@ -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;
}

View File

@ -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;
}

View 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[];
}

View 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 {}

View 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'],
},
];

View 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,
}));
}
}

View 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;
}

View 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 {}

View 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'],
},
];

View 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);
}
}

View 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;
}

View 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 {}

View 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'],
},
];

View 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>,
) {}
}

View 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;
}

View 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;
}

View 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;
}

View 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 {}

View 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'],
},
];

View 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;
}
}

View 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 };
}
}

View 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));
}
}

View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
tsconfig.json Normal file
View 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
View File

@ -0,0 +1,7 @@
extends partials/layout.pug
block title
|Icy Network | Authorize
block body
h1 Authorize

9
views/index.pug Normal file
View 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
View 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
View 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
View 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
View 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