diff options
author | Joseph Ditton <jditton.atomic@gmail.com> | 2021-11-23 14:04:12 -0700 |
---|---|---|
committer | Joseph Ditton <jditton.atomic@gmail.com> | 2021-11-23 14:04:12 -0700 |
commit | 8d0b32f8dfe45291426e58f6bf20cffac8dab6e7 (patch) | |
tree | ec4c1e08e8698d7118641612b67bce940019b3dc /server | |
parent | 4ae4e874689a71e33cdd7a5799fc0c85c4861367 (diff) | |
download | locchat-8d0b32f8dfe45291426e58f6bf20cffac8dab6e7.tar.gz locchat-8d0b32f8dfe45291426e58f6bf20cffac8dab6e7.zip |
adds api, guard, tailwind
Diffstat (limited to 'server')
-rw-r--r-- | server/app.controller.spec.ts | 13 | ||||
-rw-r--r-- | server/app.controller.ts | 8 | ||||
-rw-r--r-- | server/app.module.ts | 4 | ||||
-rw-r--r-- | server/app.service.ts | 8 | ||||
-rw-r--r-- | server/controllers/refresh_tokens.controller.ts | 32 | ||||
-rw-r--r-- | server/controllers/sessions.controller.ts | 67 | ||||
-rw-r--r-- | server/controllers/users.controller.ts | 65 | ||||
-rw-r--r-- | server/database/migrations/1637028716848-AddUser.ts | 2 | ||||
-rw-r--r-- | server/database/migrations/1637631042877-AddRefreshToken.ts | 38 | ||||
-rw-r--r-- | server/decorators/jwt_body.decorator.ts | 6 | ||||
-rw-r--r-- | server/dto/jwt_body.dto.ts | 3 | ||||
-rw-r--r-- | server/dto/refresh_token_body.dto.ts | 4 | ||||
-rw-r--r-- | server/entities/refresh_token.entity.ts | 11 | ||||
-rw-r--r-- | server/entities/user.entity.ts | 8 | ||||
-rw-r--r-- | server/main.ts | 2 | ||||
-rw-r--r-- | server/modules/users.module.ts | 10 | ||||
-rw-r--r-- | server/providers/guards/auth.guard.ts | 20 | ||||
-rw-r--r-- | server/providers/services/jwt.service.ts | 27 | ||||
-rw-r--r-- | server/providers/services/refresh_tokens.service.ts | 20 | ||||
-rw-r--r-- | server/providers/services/users.service.ts | 15 |
20 files changed, 260 insertions, 103 deletions
diff --git a/server/app.controller.spec.ts b/server/app.controller.spec.ts index d22f389..c60632d 100644 --- a/server/app.controller.spec.ts +++ b/server/app.controller.spec.ts @@ -1,6 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AppController } from './app.controller'; -import { AppService } from './app.service'; describe('AppController', () => { let appController: AppController; @@ -8,15 +7,15 @@ describe('AppController', () => { beforeEach(async () => { const app: TestingModule = await Test.createTestingModule({ controllers: [AppController], - providers: [AppService], + providers: [], }).compile(); appController = app.get<AppController>(AppController); }); - describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); - }); - }); + // describe('root', () => { + // it('should return "Hello World!"', () => { + // expect(appController.getHello()).toBe('Hello World!'); + // }); + // }); }); diff --git a/server/app.controller.ts b/server/app.controller.ts index 685cf8f..a6bcf58 100644 --- a/server/app.controller.ts +++ b/server/app.controller.ts @@ -1,12 +1,8 @@ -import { Controller, Get, Render, Req } from '@nestjs/common'; -import { Request } from 'express'; +import { Controller, Get, Render } from '@nestjs/common'; @Controller() export class AppController { @Get() @Render('index') - index(@Req() req: Request) { - const jwt = req.cookies['_token']; - return { jwt }; - } + index() {} } diff --git a/server/app.module.ts b/server/app.module.ts index 4e4f8d1..e82aa66 100644 --- a/server/app.module.ts +++ b/server/app.module.ts @@ -1,13 +1,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; -import { AppService } from './app.service'; import { config } from './database/config'; import { UsersModule } from './modules/users.module'; +import { JwtService } from './providers/services/jwt.service'; @Module({ imports: [TypeOrmModule.forRoot(config), UsersModule], controllers: [AppController], - providers: [AppService], + providers: [JwtService], }) export class AppModule {} diff --git a/server/app.service.ts b/server/app.service.ts deleted file mode 100644 index 927d7cc..0000000 --- a/server/app.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} diff --git a/server/controllers/refresh_tokens.controller.ts b/server/controllers/refresh_tokens.controller.ts new file mode 100644 index 0000000..2a24abe --- /dev/null +++ b/server/controllers/refresh_tokens.controller.ts @@ -0,0 +1,32 @@ +import { Body, Controller, Get, HttpException, Req } from '@nestjs/common'; +import { Request } from 'express'; +import { UsersService } from 'server/providers/services/users.service'; +import { SignInDto } from 'server/dto/sign_in.dto'; +import { RefreshTokenBody } from 'server/dto/refresh_token_body.dto'; +import { JwtService } from 'server/providers/services/jwt.service'; + +// this is kind of a misnomer because we are doing token based auth +// instead of session based auth +@Controller() +export class RefreshTokensController { + constructor(private usersService: UsersService, private jwtService: JwtService) {} + + @Get('/refresh_token') + async get(@Body() body: SignInDto, @Req() req: Request) { + const refreshToken: string = req.cookies['_refresh_token']; + if (!refreshToken) { + throw new HttpException('No refresh token present', 401); + } + + const tokenBody = this.jwtService.parseRefreshToken(refreshToken) as RefreshTokenBody; + + const user = await this.usersService.find(tokenBody.userId, ['refreshTokens']); + const userRefreshToken = user.refreshTokens.find((t) => t.id === tokenBody.id); + if (!userRefreshToken) { + throw new HttpException('User refresh token not found', 401); + } + + const token = this.jwtService.issueToken({ userId: user.id }); + return { token }; + } +} diff --git a/server/controllers/sessions.controller.ts b/server/controllers/sessions.controller.ts index 90b8e78..9ae647b 100644 --- a/server/controllers/sessions.controller.ts +++ b/server/controllers/sessions.controller.ts @@ -1,56 +1,53 @@ -import { - Body, - Controller, - Delete, - HttpException, - HttpStatus, - Post, - Redirect, - Res, -} from '@nestjs/common'; +import { Body, Controller, Delete, HttpException, HttpStatus, Post, Res } from '@nestjs/common'; import { Response } from 'express'; -import * as jwt from 'jsonwebtoken'; import { UsersService } from 'server/providers/services/users.service'; import { SignInDto } from 'server/dto/sign_in.dto'; - +import { JwtService } from 'server/providers/services/jwt.service'; +import { RefreshTokensService } from 'server/providers/services/refresh_tokens.service'; +import { RefreshToken } from 'server/entities/refresh_token.entity'; // this is kind of a misnomer because we are doing token based auth // instead of session based auth @Controller() export class SessionsController { - constructor(private usersService: UsersService) {} + constructor( + private usersService: UsersService, + private jwtService: JwtService, + private refreshTokenService: RefreshTokensService, + ) {} @Post('/sessions') - async create( - @Body() body: SignInDto, - @Res({ passthrough: true }) res: Response, - ) { - const { verified, user } = await this.usersService.verify( - body.email, - body.password, - ); + async create(@Body() body: SignInDto, @Res({ passthrough: true }) res: Response) { + const { verified, user } = await this.usersService.verify(body.email, body.password); if (!verified) { - throw new HttpException( - 'Invalid email or password.', - HttpStatus.BAD_REQUEST, - ); + throw new HttpException('Invalid email or password.', HttpStatus.BAD_REQUEST); + } + + let refreshToken = user.refreshTokens[0]; + if (!refreshToken) { + const newRefreshToken = new RefreshToken(); + newRefreshToken.user = user; + refreshToken = await this.refreshTokenService.create(newRefreshToken); + // generate new refresh token } - // Write JWT to cookie and send with response. - const token = jwt.sign( - { - user_id: user.id, - }, - process.env.ENCRYPTION_KEY, - { expiresIn: '1h' }, - ); - res.cookie('_token', token); + + // JWT gets sent with response + const token = this.jwtService.issueToken({ userId: user.id }); + + const refreshJwtToken = this.jwtService.issueRefreshToken({ id: refreshToken.id, userId: user.id }); + + // only refresh token should go in the cookie + res.cookie('_refresh_token', refreshJwtToken, { + httpOnly: true, // prevents javascript code from accessing cookie (helps protect against XSS attacks) + }); + return { token }; } @Delete('/sessions') async destroy(@Res({ passthrough: true }) res: Response) { - res.clearCookie('_token'); + res.clearCookie('_refresh_token'); return { success: true }; } } diff --git a/server/controllers/users.controller.ts b/server/controllers/users.controller.ts index 120a2b3..f9aba90 100644 --- a/server/controllers/users.controller.ts +++ b/server/controllers/users.controller.ts @@ -1,50 +1,57 @@ -import { - Body, - Controller, - HttpException, - HttpStatus, - Post, - Res, -} from '@nestjs/common'; +import { Body, Controller, Get, HttpException, HttpStatus, Post, Res, UseGuards } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { Response } from 'express'; -import * as jwt from 'jsonwebtoken'; +import { JwtBody } from 'server/decorators/jwt_body.decorator'; import { CreateUserDto } from 'server/dto/create_user.dto'; +import { JwtBodyDto } from 'server/dto/jwt_body.dto'; +import { RefreshToken } from 'server/entities/refresh_token.entity'; import { User } from 'server/entities/user.entity'; +import { AuthGuard } from 'server/providers/guards/auth.guard'; +import { JwtService } from 'server/providers/services/jwt.service'; +import { RefreshTokensService } from 'server/providers/services/refresh_tokens.service'; import { UsersService } from 'server/providers/services/users.service'; @Controller() export class UsersController { - constructor(private usersService: UsersService) {} + constructor( + private usersService: UsersService, + private jwtService: JwtService, + private refreshTokenService: RefreshTokensService, + ) {} + + @Get('/users/me') + @UseGuards(AuthGuard) + async getCurrentUser(@JwtBody() jwtBody: JwtBodyDto) { + const user = await this.usersService.find(jwtBody.userId); + return { user }; + } @Post('/users') - async create( - @Body() userPayload: CreateUserDto, - @Res({ passthrough: true }) res: Response, - ) { + async create(@Body() userPayload: CreateUserDto, @Res({ passthrough: true }) res: Response) { const newUser = new User(); newUser.email = userPayload.email; newUser.name = userPayload.name; - newUser.password_hash = await bcrypt.hash(userPayload.password, 10); + newUser.passwordHash = await bcrypt.hash(userPayload.password, 10); try { const user = await this.usersService.create(newUser); - // assume signup and write cookie - // Write JWT to cookie and send with response. - const token = jwt.sign( - { - user_id: user.id, - }, - process.env.ENCRYPTION_KEY, - { expiresIn: '1h' }, - ); - res.cookie('_token', token); + // create refresh token in database for user + const newRefreshToken = new RefreshToken(); + newRefreshToken.user = user; + const refreshToken = await this.refreshTokenService.create(newRefreshToken); + + // issue jwt and refreshJwtToken + const token = this.jwtService.issueToken({ userId: user.id }); + const refreshJwtToken = this.jwtService.issueRefreshToken({ id: refreshToken.id, userId: user.id }); + + // only refresh token should go in the cookie + res.cookie('_refresh_token', refreshJwtToken, { + httpOnly: true, // prevents javascript code from accessing cookie (helps protect against XSS attacks) + }); + return { user, token }; } catch (e) { - throw new HttpException( - `User creation failed. ${e.message}`, - HttpStatus.BAD_REQUEST, - ); + throw new HttpException(`User creation failed. ${e.message}`, HttpStatus.BAD_REQUEST); } } } diff --git a/server/database/migrations/1637028716848-AddUser.ts b/server/database/migrations/1637028716848-AddUser.ts index 2689d49..5cc3b7c 100644 --- a/server/database/migrations/1637028716848-AddUser.ts +++ b/server/database/migrations/1637028716848-AddUser.ts @@ -18,7 +18,7 @@ export class AddUser1637028716848 implements MigrationInterface { isNullable: false, }, { - name: 'password_hash', + name: 'passwordHash', type: 'text', isNullable: false, }, diff --git a/server/database/migrations/1637631042877-AddRefreshToken.ts b/server/database/migrations/1637631042877-AddRefreshToken.ts new file mode 100644 index 0000000..257c317 --- /dev/null +++ b/server/database/migrations/1637631042877-AddRefreshToken.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +export class AddRefreshToken1637631042877 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.createTable( + new Table({ + name: 'refresh_token', + columns: [ + { + name: 'id', + type: 'int', + isPrimary: true, + isGenerated: true, + }, + { + name: 'userId', + type: 'int', + isNullable: false, + }, + ], + }), + ); + + await queryRunner.createForeignKey( + 'refresh_token', + new TableForeignKey({ + columnNames: ['userId'], + referencedColumnNames: ['id'], + referencedTableName: 'user', + onDelete: 'CASCADE', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.dropTable('refresh_token'); + } +} diff --git a/server/decorators/jwt_body.decorator.ts b/server/decorators/jwt_body.decorator.ts new file mode 100644 index 0000000..fd8ba68 --- /dev/null +++ b/server/decorators/jwt_body.decorator.ts @@ -0,0 +1,6 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const JwtBody = createParamDecorator((data: unknown, ctx: ExecutionContext) => { + const req = ctx.switchToHttp().getRequest(); + return req.jwtBody; +}); diff --git a/server/dto/jwt_body.dto.ts b/server/dto/jwt_body.dto.ts new file mode 100644 index 0000000..f8a1179 --- /dev/null +++ b/server/dto/jwt_body.dto.ts @@ -0,0 +1,3 @@ +export interface JwtBodyDto { + userId: number; +} diff --git a/server/dto/refresh_token_body.dto.ts b/server/dto/refresh_token_body.dto.ts new file mode 100644 index 0000000..949f719 --- /dev/null +++ b/server/dto/refresh_token_body.dto.ts @@ -0,0 +1,4 @@ +export interface RefreshTokenBody { + id: number; + userId: number; +} diff --git a/server/entities/refresh_token.entity.ts b/server/entities/refresh_token.entity.ts new file mode 100644 index 0000000..9d89332 --- /dev/null +++ b/server/entities/refresh_token.entity.ts @@ -0,0 +1,11 @@ +import { Entity, PrimaryGeneratedColumn, ManyToOne } from 'typeorm'; +import { User } from './user.entity'; + +@Entity() +export class RefreshToken { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => User, (user) => user.refreshTokens) + user: User; +} diff --git a/server/entities/user.entity.ts b/server/entities/user.entity.ts index 0bc02a7..6ddbeeb 100644 --- a/server/entities/user.entity.ts +++ b/server/entities/user.entity.ts @@ -1,4 +1,5 @@ -import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; +import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm'; +import { RefreshToken } from './refresh_token.entity'; @Entity() export class User { @@ -12,5 +13,8 @@ export class User { name: string; @Column({ nullable: false }) - password_hash: string; + passwordHash: string; + + @OneToMany(() => RefreshToken, (token) => token.user) + refreshTokens: RefreshToken[]; } diff --git a/server/main.ts b/server/main.ts index 486bb86..b4f319b 100644 --- a/server/main.ts +++ b/server/main.ts @@ -21,7 +21,7 @@ async function bootstrap() { app.use(cookieParser()); app.useStaticAssets(join(__dirname, '..', 'static')); - app.setBaseViewsDir(join(__dirname, '../', 'views')); + app.setBaseViewsDir(join(__dirname, '..', 'views')); app.setViewEngine('hbs'); await app.listen(process.env.PORT); } diff --git a/server/modules/users.module.ts b/server/modules/users.module.ts index a59e24d..4519937 100644 --- a/server/modules/users.module.ts +++ b/server/modules/users.module.ts @@ -4,10 +4,14 @@ import { User } from 'server/entities/user.entity'; import { SessionsController } from '../controllers/sessions.controller'; import { UsersController } from 'server/controllers/users.controller'; import { UsersService } from '../providers/services/users.service'; +import { RefreshTokensService } from '../providers/services/refresh_tokens.service'; +import { RefreshToken } from 'server/entities/refresh_token.entity'; +import { JwtService } from 'server/providers/services/jwt.service'; +import { RefreshTokensController } from 'server/controllers/refresh_tokens.controller'; @Module({ - imports: [TypeOrmModule.forFeature([User])], - controllers: [SessionsController, UsersController], - providers: [UsersService], + imports: [TypeOrmModule.forFeature([User, RefreshToken])], + controllers: [SessionsController, UsersController, RefreshTokensController], + providers: [UsersService, RefreshTokensService, JwtService], }) export class UsersModule {} diff --git a/server/providers/guards/auth.guard.ts b/server/providers/guards/auth.guard.ts new file mode 100644 index 0000000..d7da81e --- /dev/null +++ b/server/providers/guards/auth.guard.ts @@ -0,0 +1,20 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { JwtService } from '../services/jwt.service'; + +@Injectable() +export class AuthGuard implements CanActivate { + constructor(private jwtService: JwtService) {} + + canActivate(context: ExecutionContext) { + const req = context.switchToHttp().getRequest(); + const authHeader = req.headers.authorization; + const jwt = authHeader.split(' ')[1]; + try { + req.jwtBody = this.jwtService.parseToken(jwt); + } catch (e) { + return false; + } + + return true; + } +} diff --git a/server/providers/services/jwt.service.ts b/server/providers/services/jwt.service.ts new file mode 100644 index 0000000..ac7f359 --- /dev/null +++ b/server/providers/services/jwt.service.ts @@ -0,0 +1,27 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import * as jwt from 'jsonwebtoken'; +import { JwtBodyDto } from 'server/dto/jwt_body.dto'; +import { RefreshTokenBody } from 'server/dto/refresh_token_body.dto'; + +@Injectable() +export class JwtService { + issueToken(body: JwtBodyDto | RefreshTokenBody, expiresIn = '15m', key = process.env.ENCRYPTION_KEY): string { + return jwt.sign(body, key, { expiresIn }); + } + + issueRefreshToken(body: RefreshTokenBody) { + return this.issueToken(body, '1y', process.env.REFRESH_ENCRYPTION_KEY); + } + + parseToken(token: string, key = process.env.ENCRYPTION_KEY): JwtBodyDto | RefreshTokenBody { + try { + return jwt.verify(token, key); + } catch (e) { + throw new HttpException('Invalid jwt token', 401); + } + } + + parseRefreshToken(token: string) { + return this.parseToken(token, process.env.REFRESH_ENCRYPTION_KEY); + } +} diff --git a/server/providers/services/refresh_tokens.service.ts b/server/providers/services/refresh_tokens.service.ts new file mode 100644 index 0000000..e085129 --- /dev/null +++ b/server/providers/services/refresh_tokens.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { RefreshToken } from 'server/entities/refresh_token.entity'; + +@Injectable() +export class RefreshTokensService { + constructor( + @InjectRepository(RefreshToken) + private refreshTokenRespository: Repository<RefreshToken>, + ) {} + + create(refreshToken: RefreshToken) { + return this.refreshTokenRespository.save(refreshToken); + } + + destroy(refreshToken: RefreshToken) { + return this.refreshTokenRespository.remove(refreshToken); + } +} diff --git a/server/providers/services/users.service.ts b/server/providers/services/users.service.ts index 21438a4..47a0360 100644 --- a/server/providers/services/users.service.ts +++ b/server/providers/services/users.service.ts @@ -11,12 +11,12 @@ export class UsersService { private usersRespository: Repository<User>, ) {} - findBy(options: Record<string, any>) { - return this.usersRespository.findOne(options); + findBy(options: Record<string, any>, relations: string[] = []) { + return this.usersRespository.findOne(options, { relations }); } - find(id: number) { - return this.usersRespository.findOne(id); + find(id: number, relations: string[] = []) { + return this.usersRespository.findOne(id, { relations }); } create(user: User) { @@ -24,12 +24,9 @@ export class UsersService { } async verify(email: string, password: string) { - const user = await this.usersRespository.findOne({ email }); + const user = await this.usersRespository.findOne({ email }, { relations: ['refreshTokens'] }); if (!user) return { verified: false, user: null }; - const verified: boolean = await bcrypt.compare( - password, - user.password_hash, - ); + const verified: boolean = await bcrypt.compare(password, user.passwordHash); return { verified, user: verified ? user : null }; } } |