Initial service setup

master
Evert Prants 1 year ago
commit 75a7bcfa48
Signed by: evert
GPG Key ID: 1688DA83D222D0B5

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

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

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

@ -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).

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

17432
package-lock.json generated

File diff suppressed because it is too large Load Diff

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

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

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

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

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

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -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({