summaryrefslogtreecommitdiff
path: root/server
diff options
context:
space:
mode:
authorJoseph Ditton <jditton.atomic@gmail.com>2021-11-23 14:04:12 -0700
committerJoseph Ditton <jditton.atomic@gmail.com>2021-11-23 14:04:12 -0700
commit8d0b32f8dfe45291426e58f6bf20cffac8dab6e7 (patch)
treeec4c1e08e8698d7118641612b67bce940019b3dc /server
parent4ae4e874689a71e33cdd7a5799fc0c85c4861367 (diff)
downloadlocchat-8d0b32f8dfe45291426e58f6bf20cffac8dab6e7.tar.gz
locchat-8d0b32f8dfe45291426e58f6bf20cffac8dab6e7.zip
adds api, guard, tailwind
Diffstat (limited to 'server')
-rw-r--r--server/app.controller.spec.ts13
-rw-r--r--server/app.controller.ts8
-rw-r--r--server/app.module.ts4
-rw-r--r--server/app.service.ts8
-rw-r--r--server/controllers/refresh_tokens.controller.ts32
-rw-r--r--server/controllers/sessions.controller.ts67
-rw-r--r--server/controllers/users.controller.ts65
-rw-r--r--server/database/migrations/1637028716848-AddUser.ts2
-rw-r--r--server/database/migrations/1637631042877-AddRefreshToken.ts38
-rw-r--r--server/decorators/jwt_body.decorator.ts6
-rw-r--r--server/dto/jwt_body.dto.ts3
-rw-r--r--server/dto/refresh_token_body.dto.ts4
-rw-r--r--server/entities/refresh_token.entity.ts11
-rw-r--r--server/entities/user.entity.ts8
-rw-r--r--server/main.ts2
-rw-r--r--server/modules/users.module.ts10
-rw-r--r--server/providers/guards/auth.guard.ts20
-rw-r--r--server/providers/services/jwt.service.ts27
-rw-r--r--server/providers/services/refresh_tokens.service.ts20
-rw-r--r--server/providers/services/users.service.ts15
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 };
}
}