diff --git a/client/app.jsx b/client/app.jsx index e1e4c03..90ceb37 100644 --- a/client/app.jsx +++ b/client/app.jsx @@ -6,6 +6,8 @@ import { AuthContext } from './utils/auth_context'; import { useApi } from './utils/use_api'; import { useJwtRefresh } from './utils/use_jwt_refresh'; import './app.css'; +import { RolesContext } from './utils/roles_context'; +import { parseJwt } from './utils/parse_jwt'; export const App = () => { const [authToken, setAuthToken] = useState(null); @@ -26,16 +28,21 @@ export const App = () => { setLoading(false); }, []); - // before displaying anything try getting a token using cookies, + const jwtPayload = parseJwt(authToken); + console.log(jwtPayload); + + // don't display anything while trying to get user token // can display a loading screen here if desired if (loading) return null; return ( - - - + + + + + ); diff --git a/client/components/admin/_admin.jsx b/client/components/admin/_admin.jsx new file mode 100644 index 0000000..aff544c --- /dev/null +++ b/client/components/admin/_admin.jsx @@ -0,0 +1,21 @@ +import { useState, useContext, useEffect } from 'react'; +import { ApiContext } from '../../utils/api_context'; + +export const Admin = () => { + const [users, setUsers] = useState([]); + const api = useContext(ApiContext); + + useEffect(async () => { + const { users } = await api.get('/users'); + setUsers(users); + }, []); + + return ( +
+

Users

+ {users.map((user) => ( +
{user.name}
+ ))} +
+ ); +}; diff --git a/client/components/home/_home.jsx b/client/components/home/_home.jsx index 00a7ab3..7ec9335 100644 --- a/client/components/home/_home.jsx +++ b/client/components/home/_home.jsx @@ -1,10 +1,16 @@ import { useContext, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; import { ApiContext } from '../../utils/api_context'; import { AuthContext } from '../../utils/auth_context'; +import { RolesContext } from '../../utils/roles_context'; +import { Button } from '../common/button'; export const Home = () => { const [, setAuthToken] = useContext(AuthContext); const api = useContext(ApiContext); + const roles = useContext(RolesContext); + + const navigate = useNavigate(); const [loading, setLoading] = useState(true); const [user, setUser] = useState(null); @@ -26,11 +32,16 @@ export const Home = () => { } return ( -
+

Welcome {user.name}

- + + {roles.includes('admin') && ( + + )}
); }; diff --git a/client/components/router.jsx b/client/components/router.jsx index ccdb83a..08bb41f 100644 --- a/client/components/router.jsx +++ b/client/components/router.jsx @@ -4,6 +4,7 @@ import { Home } from './home/_home'; import { AuthContext } from '../utils/auth_context'; import { SignIn } from './sign_in/_sign_in'; import { SignUp } from './sign_up/_sign_up'; +import { Admin } from './admin/_admin'; export const Router = () => { const [authToken] = useContext(AuthContext); @@ -14,6 +15,7 @@ export const Router = () => { path="/" element={authToken ? : } // no token means not logged in /> + } /> } /> } /> diff --git a/client/utils/parse_jwt.js b/client/utils/parse_jwt.js new file mode 100644 index 0000000..38b4bf9 --- /dev/null +++ b/client/utils/parse_jwt.js @@ -0,0 +1,5 @@ +export const parseJwt = (token) => { + if (!token) return {}; + const jwtPayload = JSON.parse(window.atob(token.split('.')[1])); + return jwtPayload; +}; diff --git a/client/utils/roles_context.js b/client/utils/roles_context.js new file mode 100644 index 0000000..089de6a --- /dev/null +++ b/client/utils/roles_context.js @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const RolesContext = createContext({}); diff --git a/package.json b/package.json index 440db11..8e26aa4 100644 --- a/package.json +++ b/package.json @@ -36,11 +36,13 @@ "@nestjs/platform-express": "^8.0.0", "@nestjs/serve-static": "^2.2.2", "@nestjs/typeorm": "^8.0.2", + "@types/lodash": "^4.14.177", "bcrypt": "^5.0.1", "cookie-parser": "^1.4.6", "dotenv": "^10.0.0", "hbs": "^4.1.2", "jsonwebtoken": "^8.5.1", + "lodash": "^4.17.21", "morgan": "^1.10.0", "pg": "^8.7.1", "reflect-metadata": "^0.1.13", 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 { // 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[]) => 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 = { 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[]>(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 { + const requiredRoles = this.reflector.getAllAndOverride(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, + ) {} + + 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, ) {} + findAll(relations: string[] = []) { + return this.usersRespository.find({ relations }); + } + findBy(options: Record, 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 }; diff --git a/tsconfig.json b/tsconfig.json index adb614c..1e73f62 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,6 @@ "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": false, } } diff --git a/yarn.lock b/yarn.lock index 9ec2298..43a2f71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -896,6 +896,11 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/lodash@^4.14.177": + version "4.14.177" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.177.tgz#f70c0d19c30fab101cad46b52be60363c43c4578" + integrity sha512-0fDwydE2clKe9MNfvXHBHF9WEahRuj+msTuQqOmAApNORFvhMYZKNGGJdCzuhheVjMps/ti0Ak/iJPACMaevvw== + "@types/mime@^1": version "1.3.2" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"