diff --git a/api/package.json b/api/package.json index c76a2102..e5aed3db 100644 --- a/api/package.json +++ b/api/package.json @@ -20,14 +20,23 @@ }, "dependencies": { "@godaddy/terminus": "^4.1.2", - "@nestjs/common": "^6.0.0", - "@nestjs/core": "^6.0.0", + "@nestjs/common": "^6.5.2", + "@nestjs/core": "^6.5.2", + "@nestjs/jwt": "^6.1.1", "@nestjs/mongoose": "^6.1.2", - "@nestjs/platform-express": "^6.0.0", + "@nestjs/passport": "^6.1.0", + "@nestjs/platform-express": "^6.5.2", "@nestjs/swagger": "^3.1.0", "@nestjs/terminus": "^6.5.0", + "@types/bcrypt": "^3.0.0", "@types/mongoose": "^5.5.11", + "bcrypt": "^3.0.6", + "class-transformer": "^0.2.3", + "class-validator": "^0.9.1", "mongoose": "^5.6.7", + "passport": "^0.4.0", + "passport-jwt": "^4.0.0", + "passport-local": "^1.0.0", "reflect-metadata": "^0.1.12", "rimraf": "^2.6.2", "rxjs": "^6.3.3", diff --git a/api/src/app.controller.spec.ts b/api/src/app.controller.spec.ts deleted file mode 100644 index d22f3890..00000000 --- a/api/src/app.controller.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -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); - }); - - describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); - }); - }); -}); diff --git a/api/src/app.controller.ts b/api/src/app.controller.ts index cce879ee..de422d3d 100644 --- a/api/src/app.controller.ts +++ b/api/src/app.controller.ts @@ -6,7 +6,13 @@ export class AppController { constructor(private readonly appService: AppService) {} @Get() - getHello(): string { - return this.appService.getHello(); + getIndex(): object { + return { + message: 'hello humanoid', + _links: { + doc: '/doc', + health: '/health' + } + }; } } diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 26a16ed0..ad013674 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -6,15 +6,17 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import {UsersModule} from "./users/users.module" import {FormsModule} from "./forms/forms.module" +import { AuthModule } from './auth/auth.module'; @Module({ imports: [ - MongooseModule.forRoot('mongodb://localhost/nest'), + MongooseModule.forRoot('mongodb://localhost/ohmyform'), TerminusModule.forRootAsync({ useClass: TerminusOptionsService, }), UsersModule, FormsModule, + AuthModule, ], controllers: [AppController], providers: [AppService], diff --git a/api/src/auth/auth.module.ts b/api/src/auth/auth.module.ts new file mode 100644 index 00000000..7a799daf --- /dev/null +++ b/api/src/auth/auth.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { AuthService } from './services/auth.service'; +import { UsersModule } from '../users/users.module'; +import { PassportModule } from '@nestjs/passport'; +import { PasswordStrategy } from "./strategies/password.strategy" +import { PasswordService } from "./services/password.service" +import { AuthController } from "./controllers/auth.controller" +import { jwtConstants } from "./constants" +import { JwtModule } from "@nestjs/jwt" +import { JwtStrategy } from "./strategies/jwt.strategy" +import { JwtRefreshStrategy } from "./strategies/jwt.refresh.strategy" + +@Module({ + imports: [ + UsersModule, + PassportModule, + JwtModule.register({ + secret: jwtConstants.secret, + signOptions: { expiresIn: '12h' }, + }), + ], + controllers: [AuthController], + providers: [ + AuthService, + PasswordService, + PasswordStrategy, + JwtStrategy, + JwtRefreshStrategy, + ] +}) +export class AuthModule {} diff --git a/api/src/auth/constants.ts b/api/src/auth/constants.ts new file mode 100644 index 00000000..8b22f8b6 --- /dev/null +++ b/api/src/auth/constants.ts @@ -0,0 +1,3 @@ +export const jwtConstants = { + secret: 'secretKey', +}; diff --git a/api/src/auth/controllers/auth.controller.ts b/api/src/auth/controllers/auth.controller.ts new file mode 100644 index 00000000..7826dd85 --- /dev/null +++ b/api/src/auth/controllers/auth.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Request, Post, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import {AuthService} from "../services/auth.service" + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @UseGuards(AuthGuard('password')) + @Post('login') + async login(@Request() req) { + return this.authService.createToken(req.user); + } + + @UseGuards(AuthGuard('jwt.refresh')) + @Post('refresh') + async refresh(@Request() req) { + return this.authService.createToken(req.user); + } +} diff --git a/api/src/auth/interfaces/auth.jwt.interface.ts b/api/src/auth/interfaces/auth.jwt.interface.ts new file mode 100644 index 00000000..cac955f3 --- /dev/null +++ b/api/src/auth/interfaces/auth.jwt.interface.ts @@ -0,0 +1,4 @@ +export interface AuthJwt { + access_token: string; + refresh_token: string; +} diff --git a/api/src/auth/interfaces/auth.user.interface.ts b/api/src/auth/interfaces/auth.user.interface.ts new file mode 100644 index 00000000..1a0034fb --- /dev/null +++ b/api/src/auth/interfaces/auth.user.interface.ts @@ -0,0 +1,8 @@ +export interface AuthUser { + id: string; + username: string; + email: string; + roles: [string]; + created: Date; + lastUpdated: Date; +} diff --git a/api/src/auth/services/auth.service.spec.ts b/api/src/auth/services/auth.service.spec.ts new file mode 100644 index 00000000..800ab662 --- /dev/null +++ b/api/src/auth/services/auth.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from './auth.service'; + +describe('AuthService', () => { + let service: AuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AuthService], + }).compile(); + + service = module.get(AuthService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/api/src/auth/services/auth.service.ts b/api/src/auth/services/auth.service.ts new file mode 100644 index 00000000..e607da5a --- /dev/null +++ b/api/src/auth/services/auth.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { UsersService } from '../../users/users.service'; +import { PasswordService } from "./password.service" +import { JwtService } from '@nestjs/jwt'; +import { AuthUser } from "../interfaces/auth.user.interface" +import { User } from "../../users/interfaces/user.interface" +import {AuthJwt} from "../interfaces/auth.jwt.interface" + +@Injectable() +export class AuthService { + constructor( + private readonly usersService: UsersService, + private readonly passwordService: PasswordService, + private readonly jwtService: JwtService + ) {} + + async verifyForLogin(identifier: string, pass: string): Promise { + try { + const user = await this.usersService.findOneByIdentifier(identifier); + + if (await this.passwordService.verify(pass, user.passwordHash, user.salt)) { + return AuthService.setupAuthUser(user) + } + } catch (e) {} + + return null; + } + + async validate(identifier: string): Promise { + try { + return AuthService.setupAuthUser( + await this.usersService.findOneByIdentifier(identifier) + ) + } catch (e) {} + + return null; + } + + private static setupAuthUser(user:User):AuthUser { + return { + id: user.id, + username: user.username, + email: user.email, + roles: user.roles, + created: user.created, + lastUpdated: user.lastUpdated + }; + } + + async createToken(user: AuthUser): Promise { + const payload = { + id: user.id, + username: user.username, + roles: user.roles, + }; + return { + access_token: this.jwtService.sign(payload), + // TODO add refresh token invalidation uppon usage! They should only work once + refresh_token: this.jwtService.sign( + { + ...payload, + refresh: true + }, + { + expiresIn: '30days', + } + ), + }; + } +} diff --git a/api/src/auth/services/password.service.ts b/api/src/auth/services/password.service.ts new file mode 100644 index 00000000..548926b9 --- /dev/null +++ b/api/src/auth/services/password.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from "@nestjs/common" +import * as crypto from 'crypto' +import * as bcrypt from 'bcrypt' + +@Injectable() +export class PasswordService { + async verify (password: string, hash: string, salt?: string): Promise { + console.log('try to verify password', password) + if (password[0] === '$') { + return await bcrypt.compare(password, hash) + } + + //Generate salt if it doesn't exist yet + if(!salt){ + salt = crypto.randomBytes(64).toString('base64'); + } + + return hash === crypto.pbkdf2Sync( + password, + new Buffer(salt, 'base64'), + 10000, + 128, + 'SHA1' + ).toString('base64'); + } + + hash (password): Promise { + return bcrypt.hash(password, 4) + } +} diff --git a/api/src/auth/strategies/jwt.refresh.strategy.ts b/api/src/auth/strategies/jwt.refresh.strategy.ts new file mode 100644 index 00000000..2b8c20e5 --- /dev/null +++ b/api/src/auth/strategies/jwt.refresh.strategy.ts @@ -0,0 +1,26 @@ +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { jwtConstants } from '../constants'; +import { AuthService } from "../services/auth.service" +import {AuthUser} from "../interfaces/auth.user.interface" + +@Injectable() +export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt.refresh') { + constructor(private readonly authService: AuthService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: jwtConstants.secret, + }); + } + + async validate(payload: any): Promise { + if (!payload.refresh) { + return null + } + + // TODO add refresh token invalidation uppon usage! They should only work once + return this.authService.validate(payload.username); + } +} diff --git a/api/src/auth/strategies/jwt.strategy.ts b/api/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 00000000..967a0188 --- /dev/null +++ b/api/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,21 @@ +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { jwtConstants } from '../constants'; +import { AuthService } from "../services/auth.service" +import {AuthUser} from "../interfaces/auth.user.interface" + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(private readonly authService: AuthService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: jwtConstants.secret, + }); + } + + async validate(payload: any): Promise { + return this.authService.validate(payload.username); + } +} diff --git a/api/src/auth/strategies/password.strategy.ts b/api/src/auth/strategies/password.strategy.ts new file mode 100644 index 00000000..a1d4a10f --- /dev/null +++ b/api/src/auth/strategies/password.strategy.ts @@ -0,0 +1,16 @@ +import { Strategy } from 'passport-local'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { AuthService } from '../services/auth.service'; +import {AuthUser} from "../interfaces/auth.user.interface" + +@Injectable() +export class PasswordStrategy extends PassportStrategy(Strategy, 'password') { + constructor(private readonly authService: AuthService) { + super(); + } + + validate(username: string, password: string): Promise { + return this.authService.verifyForLogin(username, password); + } +} diff --git a/api/src/main.ts b/api/src/main.ts index 1e7989a5..8bd56150 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -1,12 +1,20 @@ import { NestFactory } from '@nestjs/core'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { AppModule } from './app.module'; - +import { ValidationPipe } from '@nestjs/common'; const pkg = require('../package.json') async function bootstrap() { const app = await NestFactory.create(AppModule); + // app.enableCors({ origin: '*' }); + // app.getHttpAdapter().options('*', cors()); + + app.useGlobalPipes(new ValidationPipe({ + disableErrorMessages: false, + transform: true, + })); + const options = new DocumentBuilder() .setTitle('OhMyForm') .setDescription('API documentation') diff --git a/api/src/users/interfaces/user.interface.ts b/api/src/users/interfaces/user.interface.ts new file mode 100644 index 00000000..1cd168c3 --- /dev/null +++ b/api/src/users/interfaces/user.interface.ts @@ -0,0 +1,19 @@ +import { Document } from 'mongoose'; + +export interface User extends Document{ + readonly firstName: string; + readonly lastName: string; + readonly email: string; + readonly username: string; + readonly passwordHash: string; + readonly salt: string; + readonly provider: string; + readonly roles: [string]; + readonly language: string; + readonly created: Date; + readonly lastUpdated: Date; + readonly resetPasswordToken: string; + readonly resetPasswordExpires: Date; + readonly token: string; + readonly apiKey: string; +} diff --git a/api/src/users/users.module.ts b/api/src/users/users.module.ts index fa1c4450..82398af1 100644 --- a/api/src/users/users.module.ts +++ b/api/src/users/users.module.ts @@ -1,7 +1,11 @@ import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { UsersService } from './users.service'; +import {UserSchema} from "./schemas/user.schema" @Module({ - providers: [], - exports: [], + imports: [MongooseModule.forFeature([{ name: 'User', schema: UserSchema }])], + providers: [UsersService], + exports: [UsersService], }) export class UsersModule {} diff --git a/api/src/users/users.service.spec.ts b/api/src/users/users.service.spec.ts new file mode 100644 index 00000000..62815ba6 --- /dev/null +++ b/api/src/users/users.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersService } from './users.service'; + +describe('UsersService', () => { + let service: UsersService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UsersService], + }).compile(); + + service = module.get(UsersService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/api/src/users/users.service.ts b/api/src/users/users.service.ts new file mode 100644 index 00000000..ced6a32f --- /dev/null +++ b/api/src/users/users.service.ts @@ -0,0 +1,28 @@ +import { Model } from 'mongoose'; +import {Injectable, NotFoundException} from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import {User} from "./interfaces/user.interface" + +export type User = any; + +@Injectable() +export class UsersService { + constructor(@InjectModel('User') private readonly userModel: Model) {} + + async findOneByIdentifier(identifier: string): Promise { + const results = await this.userModel.find().or([ + { + username: identifier + }, + { + email: identifier + } + ]).exec(); + + if (results.length !== 1) { + throw new NotFoundException() + } + + return results[0] + } +}