diff options
author | Joseph Ditton <jditton.atomic@gmail.com> | 2021-12-01 20:18:26 -0700 |
---|---|---|
committer | Joseph Ditton <jditton.atomic@gmail.com> | 2021-12-01 20:18:26 -0700 |
commit | 84b45cd6b11347e66437cd92dc20372d0abd6eb9 (patch) | |
tree | 6e42b5861278485c67159dc57c225983e3fd69f8 /server | |
parent | d803aaaf1be441f55fe674c3b0c6793e77a9203f (diff) | |
download | locchat-84b45cd6b11347e66437cd92dc20372d0abd6eb9.tar.gz locchat-84b45cd6b11347e66437cd92dc20372d0abd6eb9.zip |
adds roles
Diffstat (limited to 'server')
-rw-r--r-- | server/app.controller.ts | 3 | ||||
-rw-r--r-- | server/app.module.ts | 13 | ||||
-rw-r--r-- | server/controllers/refresh_tokens.controller.ts | 12 | ||||
-rw-r--r-- | server/controllers/sessions.controller.ts | 9 | ||||
-rw-r--r-- | server/controllers/users.controller.ts | 25 | ||||
-rw-r--r-- | server/database/seeds.ts | 8 | ||||
-rw-r--r-- | server/decorators/roles.decorator.ts | 5 | ||||
-rw-r--r-- | server/decorators/skip.decorator.ts | 5 | ||||
-rw-r--r-- | server/dto/class.dto.ts | 1 | ||||
-rw-r--r-- | server/dto/jwt_body.dto.ts | 3 | ||||
-rw-r--r-- | server/entities/role.entity.ts | 15 | ||||
-rw-r--r-- | server/entities/user_role.entity.ts | 8 | ||||
-rw-r--r-- | server/modules/users.module.ts | 8 | ||||
-rw-r--r-- | server/providers/guards/auth.guard.ts | 17 | ||||
-rw-r--r-- | server/providers/guards/roles.guard.ts | 37 | ||||
-rw-r--r-- | server/providers/services/roles.service.ts | 25 | ||||
-rw-r--r-- | server/providers/services/users.service.ts | 6 |
17 files changed, 176 insertions, 24 deletions
diff --git a/server/app.controller.ts b/server/app.controller.ts index a6bcf58..53c975f 100644 --- a/server/app.controller.ts +++ b/server/app.controller.ts @@ -1,8 +1,11 @@ import { Controller, Get, Render } from '@nestjs/common'; +import { Skip } from './decorators/skip.decorator'; +import { AuthGuard } from './providers/guards/auth.guard'; @Controller() export class AppController { @Get() @Render('index') + @Skip(AuthGuard) index() {} } diff --git a/server/app.module.ts b/server/app.module.ts index e82aa66..41446f8 100644 --- a/server/app.module.ts +++ b/server/app.module.ts @@ -1,13 +1,24 @@ import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; import { config } from './database/config'; import { UsersModule } from './modules/users.module'; +import { AuthGuard } from './providers/guards/auth.guard'; +import { RolesGuard } from './providers/guards/roles.guard'; import { JwtService } from './providers/services/jwt.service'; +import { RolesService } from './providers/services/roles.service'; +import { UsersService } from './providers/services/users.service'; @Module({ imports: [TypeOrmModule.forRoot(config), UsersModule], controllers: [AppController], - providers: [JwtService], + providers: [ + UsersService, + RolesService, + JwtService, + { provide: APP_GUARD, useClass: AuthGuard }, // auth guard should come before roles guard + { provide: APP_GUARD, useClass: RolesGuard }, // otherwise users won't be authenticated before roles check + ], }) export class AppModule {} diff --git a/server/controllers/refresh_tokens.controller.ts b/server/controllers/refresh_tokens.controller.ts index 2a24abe..6aa696f 100644 --- a/server/controllers/refresh_tokens.controller.ts +++ b/server/controllers/refresh_tokens.controller.ts @@ -4,14 +4,18 @@ 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'; +import { Skip } from 'server/decorators/skip.decorator'; +import { AuthGuard } from 'server/providers/guards/auth.guard'; +import { RolesService } from 'server/providers/services/roles.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) {} + constructor(private usersService: UsersService, private rolesService: RolesService, private jwtService: JwtService) {} @Get('/refresh_token') + @Skip(AuthGuard) async get(@Body() body: SignInDto, @Req() req: Request) { const refreshToken: string = req.cookies['_refresh_token']; if (!refreshToken) { @@ -20,13 +24,15 @@ export class RefreshTokensController { const tokenBody = this.jwtService.parseRefreshToken(refreshToken) as RefreshTokenBody; - const user = await this.usersService.find(tokenBody.userId, ['refreshTokens']); + const user = await this.usersService.find(tokenBody.userId, ['refreshTokens', 'userRoles']); + const userRoles = await this.rolesService.findByIds(user.userRoles.map((ur) => ur.roleId)); + 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 }); + const token = this.jwtService.issueToken({ userId: user.id, roles: userRoles.map((r) => r.key) }); return { token }; } } diff --git a/server/controllers/sessions.controller.ts b/server/controllers/sessions.controller.ts index 9ae647b..e1d1155 100644 --- a/server/controllers/sessions.controller.ts +++ b/server/controllers/sessions.controller.ts @@ -5,6 +5,9 @@ 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'; +import { Skip } from 'server/decorators/skip.decorator'; +import { AuthGuard } from 'server/providers/guards/auth.guard'; +import { RolesService } from 'server/providers/services/roles.service'; // this is kind of a misnomer because we are doing token based auth // instead of session based auth @@ -13,10 +16,12 @@ export class SessionsController { constructor( private usersService: UsersService, private jwtService: JwtService, + private rolesService: RolesService, private refreshTokenService: RefreshTokensService, ) {} @Post('/sessions') + @Skip(AuthGuard) async create(@Body() body: SignInDto, @Res({ passthrough: true }) res: Response) { const { verified, user } = await this.usersService.verify(body.email, body.password); @@ -32,8 +37,10 @@ export class SessionsController { // generate new refresh token } + const userRoles = await this.rolesService.findByIds(user.userRoles.map((ur) => ur.roleId)); + // JWT gets sent with response - const token = this.jwtService.issueToken({ userId: user.id }); + const token = this.jwtService.issueToken({ userId: user.id, roles: userRoles.map((r) => r.key) }); const refreshJwtToken = this.jwtService.issueRefreshToken({ id: refreshToken.id, userId: user.id }); diff --git a/server/controllers/users.controller.ts b/server/controllers/users.controller.ts index f9aba90..fda71b3 100644 --- a/server/controllers/users.controller.ts +++ b/server/controllers/users.controller.ts @@ -2,36 +2,53 @@ import { Body, Controller, Get, HttpException, HttpStatus, Post, Res, UseGuards import * as bcrypt from 'bcrypt'; import { Response } from 'express'; import { JwtBody } from 'server/decorators/jwt_body.decorator'; +import { Roles } from 'server/decorators/roles.decorator'; +import { Skip } from 'server/decorators/skip.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 { RoleKey } from 'server/entities/role.entity'; import { User } from 'server/entities/user.entity'; +import { UserRole } from 'server/entities/user_role.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 { RolesService } from 'server/providers/services/roles.service'; import { UsersService } from 'server/providers/services/users.service'; @Controller() export class UsersController { constructor( private usersService: UsersService, + private rolesService: RolesService, private jwtService: JwtService, private refreshTokenService: RefreshTokensService, ) {} + @Get('/users') + @Roles(RoleKey.ADMIN) + async index() { + const users = await this.usersService.findAll(); + return { users }; + } + @Get('/users/me') - @UseGuards(AuthGuard) async getCurrentUser(@JwtBody() jwtBody: JwtBodyDto) { const user = await this.usersService.find(jwtBody.userId); return { user }; } @Post('/users') + @Skip(AuthGuard) async create(@Body() userPayload: CreateUserDto, @Res({ passthrough: true }) res: Response) { const newUser = new User(); newUser.email = userPayload.email; newUser.name = userPayload.name; newUser.passwordHash = await bcrypt.hash(userPayload.password, 10); + const [role] = await this.rolesService.findByKey(RoleKey.USER); + const userRole = new UserRole(); + userRole.role = role; + newUser.userRoles = [userRole]; try { const user = await this.usersService.create(newUser); @@ -39,9 +56,11 @@ export class UsersController { 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 }); + // note the roles hard coded to just USER. + // If you want to allow users to sign up as different roles then + // you will need to update this here. + const token = this.jwtService.issueToken({ userId: user.id, roles: [RoleKey.USER] }); const refreshJwtToken = this.jwtService.issueRefreshToken({ id: refreshToken.id, userId: user.id }); // only refresh token should go in the cookie diff --git a/server/database/seeds.ts b/server/database/seeds.ts index 101b48b..94e69ef 100644 --- a/server/database/seeds.ts +++ b/server/database/seeds.ts @@ -1,7 +1,7 @@ import { Factory, Seeder } from 'typeorm-seeding'; -import { Connection, Db } from 'typeorm'; +import { Connection } from 'typeorm'; import { User } from '../entities/user.entity'; -import { Role } from '../entities/role.entity'; +import { Role, RoleKey } from '../entities/role.entity'; import * as dotenv from 'dotenv'; import * as bcrypt from 'bcrypt'; import { UserRole } from '../entities/user_role.entity'; @@ -11,6 +11,7 @@ export default class Seeds implements Seeder { public async run(factory: Factory, connection: Connection): Promise<any> { // CREATE ROLES console.log('\nCreating Roles'); + const roleObjects = Role.ROLES.map((key) => ({ key })); const roleRepository = connection.getRepository(Role); for (const roleObj of roleObjects) { @@ -26,10 +27,9 @@ export default class Seeds implements Seeder { // CREATE ADMIN USER const userRepository = connection.getRepository(User); - const userRoleRepository = connection.getRepository(UserRole); let adminUser = await userRepository.findOne({ email: process.env.ADMIN_EMAIL }); if (!adminUser) { - const adminRole = await roleRepository.findOne({ key: Role.ADMIN }); + const adminRole = await roleRepository.findOne({ key: RoleKey.ADMIN }); console.log(`\nCreating Admin User with email ${process.env.ADMIN_EMAIL}`); console.log(adminRole); const passwordHash = await bcrypt.hash(process.env.ADMIN_PASSWORD, 10); diff --git a/server/decorators/roles.decorator.ts b/server/decorators/roles.decorator.ts new file mode 100644 index 0000000..c51d3d0 --- /dev/null +++ b/server/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { RoleKey } from 'server/entities/role.entity'; + +export const ROLES_CONTEXT_KEY = 'roles'; +export const Roles = (...roles: RoleKey[]) => SetMetadata(ROLES_CONTEXT_KEY, roles); diff --git a/server/decorators/skip.decorator.ts b/server/decorators/skip.decorator.ts new file mode 100644 index 0000000..6cd438f --- /dev/null +++ b/server/decorators/skip.decorator.ts @@ -0,0 +1,5 @@ +import { CanActivate, SetMetadata } from '@nestjs/common'; +import { Class } from 'server/dto/class.dto'; + +export const SKIP_KEY = 'skip'; +export const Skip = (...guards: Class<CanActivate>[]) => SetMetadata(SKIP_KEY, guards); diff --git a/server/dto/class.dto.ts b/server/dto/class.dto.ts new file mode 100644 index 0000000..b4181a1 --- /dev/null +++ b/server/dto/class.dto.ts @@ -0,0 +1 @@ +export type Class<T> = { new (...args: any[]): T }; diff --git a/server/dto/jwt_body.dto.ts b/server/dto/jwt_body.dto.ts index f8a1179..dc2e1b6 100644 --- a/server/dto/jwt_body.dto.ts +++ b/server/dto/jwt_body.dto.ts @@ -1,3 +1,6 @@ +import { RoleKey } from 'server/entities/role.entity'; + export interface JwtBodyDto { userId: number; + roles: RoleKey[]; } diff --git a/server/entities/role.entity.ts b/server/entities/role.entity.ts index 35b4ac3..da33726 100644 --- a/server/entities/role.entity.ts +++ b/server/entities/role.entity.ts @@ -1,20 +1,21 @@ import { Entity, PrimaryGeneratedColumn, OneToMany, Column } from 'typeorm'; import { UserRole } from './user_role.entity'; +// Make sure to add aditional roles here then reseed +export enum RoleKey { + ADMIN = 'admin', + USER = 'user', +} + @Entity() export class Role { - static ADMIN = 'admin'; - static USER = 'user'; - - // make sure add additional roles to this arraylist as it - // will be used during seeds to initiallize all roles. - static ROLES = [Role.ADMIN, Role.USER]; + static ROLES = [RoleKey.ADMIN, RoleKey.USER]; @PrimaryGeneratedColumn() id: number; @Column() - key: string; + key: RoleKey; @OneToMany(() => UserRole, (userRole) => userRole.role) userRoles: UserRole[]; diff --git a/server/entities/user_role.entity.ts b/server/entities/user_role.entity.ts index 0a6c5c6..e680a3a 100644 --- a/server/entities/user_role.entity.ts +++ b/server/entities/user_role.entity.ts @@ -1,4 +1,4 @@ -import { Entity, PrimaryGeneratedColumn, ManyToOne } from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, ManyToOne, Column } from 'typeorm'; import { Role } from './role.entity'; import { User } from './user.entity'; @@ -7,6 +7,12 @@ export class UserRole { @PrimaryGeneratedColumn() id: number; + @Column() + roleId: number; + + @Column() + userId: number; + @ManyToOne(() => Role, (role) => role.userRoles) role: Role; diff --git a/server/modules/users.module.ts b/server/modules/users.module.ts index 4519937..69c533b 100644 --- a/server/modules/users.module.ts +++ b/server/modules/users.module.ts @@ -8,10 +8,14 @@ import { RefreshTokensService } from '../providers/services/refresh_tokens.servi import { RefreshToken } from 'server/entities/refresh_token.entity'; import { JwtService } from 'server/providers/services/jwt.service'; import { RefreshTokensController } from 'server/controllers/refresh_tokens.controller'; +import { Role } from 'server/entities/role.entity'; +import { RolesService } from 'server/providers/services/roles.service'; +import { UserRole } from 'server/entities/user_role.entity'; @Module({ - imports: [TypeOrmModule.forFeature([User, RefreshToken])], + imports: [TypeOrmModule.forFeature([User, RefreshToken, Role, UserRole])], controllers: [SessionsController, UsersController, RefreshTokensController], - providers: [UsersService, RefreshTokensService, JwtService], + providers: [UsersService, RolesService, RefreshTokensService, JwtService], + exports: [TypeOrmModule], }) export class UsersModule {} diff --git a/server/providers/guards/auth.guard.ts b/server/providers/guards/auth.guard.ts index d7da81e..8c03ad8 100644 --- a/server/providers/guards/auth.guard.ts +++ b/server/providers/guards/auth.guard.ts @@ -1,13 +1,28 @@ import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { JwtService } from '../services/jwt.service'; +import { SKIP_KEY } from 'server/decorators/skip.decorator'; +import { Reflector } from '@nestjs/core'; +import { Class } from 'server/dto/class.dto'; @Injectable() export class AuthGuard implements CanActivate { - constructor(private jwtService: JwtService) {} + constructor(private reflector: Reflector, private jwtService: JwtService) {} canActivate(context: ExecutionContext) { + const skippedGuards = this.reflector.getAllAndOverride<Class<CanActivate>[]>(SKIP_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (skippedGuards) { + const skippedGuard = skippedGuards.find((guard) => this instanceof guard); + if (skippedGuard) { + return true; + } + } const req = context.switchToHttp().getRequest(); const authHeader = req.headers.authorization; + if (!authHeader) return false; + const jwt = authHeader.split(' ')[1]; try { req.jwtBody = this.jwtService.parseToken(jwt); diff --git a/server/providers/guards/roles.guard.ts b/server/providers/guards/roles.guard.ts index e69de29..3ecc392 100644 --- a/server/providers/guards/roles.guard.ts +++ b/server/providers/guards/roles.guard.ts @@ -0,0 +1,37 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_CONTEXT_KEY } from 'server/decorators/roles.decorator'; +import { JwtBodyDto } from 'server/dto/jwt_body.dto'; +import { RoleKey } from 'server/entities/role.entity'; +import { RolesService } from '../services/roles.service'; +import { UsersService } from '../services/users.service'; +import { some } from 'lodash'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector, private usersService: UsersService, private rolesService: RolesService) {} + + async canActivate(context: ExecutionContext): Promise<boolean> { + const requiredRoles = this.reflector.getAllAndOverride<RoleKey[]>(ROLES_CONTEXT_KEY, [ + context.getHandler(), + context.getClass(), + ]); + console.log(requiredRoles); + + if (!requiredRoles) { + return true; + } + + const jwtBody: JwtBodyDto = context.switchToHttp().getRequest().jwtBody; + + if (!jwtBody) return false; // unauthenticated users are not authorized + + const user = await this.usersService.find(jwtBody.userId, ['userRoles']); + const roles = await this.rolesService.findByKey(...requiredRoles); + const roleMatches = user.userRoles.map((userRole) => { + return !!roles.find((role) => role.id === userRole.roleId); + }); + + return some(roleMatches); + } +} diff --git a/server/providers/services/roles.service.ts b/server/providers/services/roles.service.ts new file mode 100644 index 0000000..7cd17ef --- /dev/null +++ b/server/providers/services/roles.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import * as bcrypt from 'bcrypt'; +import { Role, RoleKey } from 'server/entities/role.entity'; + +@Injectable() +export class RolesService { + constructor( + @InjectRepository(Role) + private rolesRepository: Repository<Role>, + ) {} + + findByKey(...keys: RoleKey[]) { + return this.rolesRepository.find({ where: { key: In(keys) } }); + } + + findByIds(ids: number[]) { + return this.rolesRepository.findByIds(ids); + } + + find(id: number, relations: string[] = []) { + return this.rolesRepository.findOne(id, { relations }); + } +} diff --git a/server/providers/services/users.service.ts b/server/providers/services/users.service.ts index 47a0360..c3ee086 100644 --- a/server/providers/services/users.service.ts +++ b/server/providers/services/users.service.ts @@ -11,6 +11,10 @@ export class UsersService { private usersRespository: Repository<User>, ) {} + findAll(relations: string[] = []) { + return this.usersRespository.find({ relations }); + } + findBy(options: Record<string, any>, relations: string[] = []) { return this.usersRespository.findOne(options, { relations }); } @@ -24,7 +28,7 @@ export class UsersService { } async verify(email: string, password: string) { - const user = await this.usersRespository.findOne({ email }, { relations: ['refreshTokens'] }); + const user = await this.usersRespository.findOne({ email }, { relations: ['refreshTokens', 'userRoles'] }); if (!user) return { verified: false, user: null }; const verified: boolean = await bcrypt.compare(password, user.passwordHash); return { verified, user: verified ? user : null }; |