Skip to content

Commit

Permalink
feat(permissions): role based access control (#23)
Browse files Browse the repository at this point in the history
  • Loading branch information
Ealenn committed Jun 19, 2022
1 parent 4c62d02 commit dabc7c8
Show file tree
Hide file tree
Showing 19 changed files with 134 additions and 44 deletions.
16 changes: 10 additions & 6 deletions admin/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,16 @@ const App = () => (
layout={OpenGardenAdminLayout}
authProvider={AuthProvider}
dataProvider={Provider(BASE_URL, HttpClient)}>
<Resource name="plants"
create={PlantCreate}
edit={PlantEdit}
list={PlantsList}
show={PlantShow}
icon={IoFlower} />
{permissions => (
<>
<Resource name="plants"
create={permissions.includes('ADMIN') ? PlantCreate : null}
edit={permissions.includes('ADMIN') ? PlantEdit : null}
list={PlantsList}
show={PlantShow}
icon={IoFlower} />
</>
)}
</Admin>
);

Expand Down
43 changes: 28 additions & 15 deletions admin/src/configs/AuthProvider.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,37 @@
import axios from 'axios';
import { BASE_URL } from './BaseUrl';
import jwt_decode from "jwt-decode";
import { GetPermissions } from '../helpers/GetPermissions';

export const AuthProvider = {
login: (params) => new Promise((resolve, reject) => {
axios.post(BASE_URL + '/account/login', {
return axios.post(BASE_URL + '/account/login', {
email: params.username,
password: params.password
}, {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
})
.then(request => {
localStorage.setItem('auth', JSON.stringify(request.data.access_token));
return resolve();
})
.catch(exception => {
return reject(exception);
})
}).then(request => {
localStorage.setItem('access_token', JSON.stringify(request.data.access_token));
localStorage.setItem('user', JSON.stringify(jwt_decode(request.data.access_token)));
return resolve();
}).catch(() => {
return reject();
});
}),
logout: () => {
localStorage.removeItem('auth');
localStorage.removeItem('access_token');
localStorage.removeItem('user');
return Promise.resolve();
},
checkAuth: () => new Promise((resolve, reject) => {
const ticket = jwt_decode(localStorage.getItem('auth'));
if (!localStorage.getItem('access_token') || !localStorage.getItem('user')) {
return reject();
}

const ticket = JSON.parse(localStorage.getItem('user'));
const expiration = Math.floor((Date.now() / 1000) - (1000 * 60 * 5));

if (ticket.exp < expiration) {
Expand All @@ -37,15 +42,23 @@ export const AuthProvider = {
checkError: (error) => {
const status = error.status;
if (status === 401 || status === 403) {
localStorage.removeItem('auth');
localStorage.removeItem('access_token');
localStorage.removeItem('user');
return Promise.reject();
}
return Promise.resolve();
},
getIdentity: () => {
const user = jwt_decode(localStorage.getItem('auth'));
const user = JSON.parse(localStorage.getItem('user'));
user.fullName = user.username;
return user;
return Promise.resolve(user);
},
getPermissions: () => {
try {
return Promise.resolve(GetPermissions());
} catch (exception) {
console.log('getPermissions error', exception)
return Promise.resolve([]);
}
},
getPermissions: () => Promise.resolve(''),
}
2 changes: 1 addition & 1 deletion admin/src/configs/HttpClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export const HttpClient = (url, options = {}) => {
if (!options.headers) {
options.headers = new Headers({ Accept: "application/json" });
}
const access_token = JSON.parse(localStorage.getItem("auth"));
const access_token = JSON.parse(localStorage.getItem("access_token"));
options.headers.set("Authorization", `Bearer ${access_token}`);
return fetchUtils.fetchJson(url, options);
};
1 change: 0 additions & 1 deletion admin/src/configs/Provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { fetchUtils, DataProvider } from 'ra-core';
export default (apiUrl, httpClient = fetchUtils.fetchJson): DataProvider => ({
getList: (resource, params) => {
const { page, perPage } = params.pagination;
// const { field, order } = params.sort;
const query = {
...fetchUtils.flattenObject(params.filter),
offset: (page - 1) * perPage,
Expand Down
5 changes: 5 additions & 0 deletions admin/src/helpers/GetPermissions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const GetPermissions = () => {
const user = JSON.parse(localStorage.getItem('user'));
const permissions = user.roles || [];
return permissions;
}
46 changes: 25 additions & 21 deletions admin/src/views/plants.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {
Show,
RichTextField,
ReferenceOneField,
EditButton,
ShowButton,
EditButton,
Create,
TextInput,
SimpleFormIterator,
Expand All @@ -24,9 +24,10 @@ import {
} from "react-admin";
import { RichTextInput } from 'ra-input-rich-text';
import { StringToLabelObject } from '../helpers/StringToLabelObject';
import { GetPermissions } from "../helpers/GetPermissions";

export const PlantCreate = () => (
<Create>
export const PlantCreate = (props) => (
<Create {...props}>
<TabbedForm>
<FormTab label="Plant">
<TextInput source="name" />
Expand All @@ -49,8 +50,8 @@ export const PlantCreate = () => (
</Create>
);

export const PlantEdit = () => (
<Edit>
export const PlantEdit = (props) => (
<Edit {...props}>
<TabbedForm>
<FormTab label="Plant">
<TextInput source="name" />
Expand All @@ -73,24 +74,27 @@ export const PlantEdit = () => (
</Edit>
);

export const PlantsList = (props) => (
<List {...props}>
<Datagrid>
<TextField source="name" />
<TextField source="classification.binomialName" />
<ReferenceOneField reference="profiles" source="createdBy">
<TextField source="username" />
</ReferenceOneField>
<DateField source="createdAt" />
<DateField source="updatedAt" />
<EditButton />
<ShowButton />
</Datagrid>
</List>
);
export const PlantsList = (props) => {
const permissions = GetPermissions();
return (
<List {...props} exporter={false}>
<Datagrid isRowSelectable={() => permissions.includes('ADMIN')}>
<TextField source="name" />
<TextField source="classification.binomialName" />
<ReferenceOneField reference="profiles" source="createdBy">
<TextField source="username" />
</ReferenceOneField>
<DateField source="createdAt" />
<DateField source="updatedAt" />
{permissions.includes('ADMIN') && <EditButton />}
<ShowButton />
</Datagrid>
</List>
);
};

export const PlantShow = (props) => (
<Show>
<Show {...props}>
<TabbedShowLayout>
<Tab label="Summary">
<TextField source="name" />
Expand Down
5 changes: 5 additions & 0 deletions core/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { JwtGuard } from './auth/jwt.guard';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { ControllersModule } from './controllers/controllers.module';
import { EntitiesModule } from './entities/entities.module';
import { RolesGuard } from './auth/roles/roles.guard';

@Module({
imports: [
Expand Down Expand Up @@ -44,6 +45,10 @@ import { EntitiesModule } from './entities/entities.module';
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AppModule {}
1 change: 1 addition & 0 deletions core/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class AuthService {
sub: user._id.toString(),
email: user.email,
username: user.username,
roles: user.roles,
};
return {
access_token: this.jwtService.sign(payload),
Expand Down
3 changes: 3 additions & 0 deletions core/src/auth/models/jwt.content.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Role } from '../roles/role.enum';

export interface JwtContent {
/**
* User ID
*/
sub: string;
username: string;
email: string;
roles: Role[];
}
4 changes: 4 additions & 0 deletions core/src/auth/roles/role.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum Role {
USER = 'USER',
ADMIN = 'ADMIN',
}
5 changes: 5 additions & 0 deletions core/src/auth/roles/roles.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from './role.enum';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
21 changes: 21 additions & 0 deletions core/src/auth/roles/roles.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from './role.enum';
import { ROLES_KEY } from './roles.decorator';

@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}

canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
2 changes: 2 additions & 0 deletions core/src/controllers/account/account.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { UsersService } from '../../users/users.service';
import { RegisterRequestBody } from './models/register.request.body';
import { HashService } from '../../auth/hash.service';
import { ErrorsRequestBody } from '../models/errors.response.body';
import { Role } from '../../auth/roles/role.enum';

@ApiTags('Account')
@Controller('/account')
Expand Down Expand Up @@ -53,6 +54,7 @@ export class AccountController {
const createUser: User = {
...registerRequestBody,
_id: null,
roles: [Role.USER],
password: this.hashService.hash(registerRequestBody.password),
};
const user = await this.usersService.create(createUser);
Expand Down
4 changes: 4 additions & 0 deletions core/src/controllers/floors/floors.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { CreateFloorRequestBody } from './models/floor.request.body';
import { FloorsSearchRequestQuery } from './models/floor.request.query';
import { ErrorsRequestBody } from '../models/errors.response.body';
import { Response as Res } from 'express';
import { Roles } from '../../auth/roles/roles.decorator';
import { Role } from '../../auth/roles/role.enum';

@ApiBearerAuth()
@ApiTags('Floors')
Expand All @@ -30,6 +32,8 @@ export class FloorsController {
constructor(private floorsService: FloorsService, @InjectMapper() private mapper: Mapper) {}

@Post()
@Roles(Role.ADMIN)
@ApiResponse({ status: 403, description: 'Forbidden' })
@ApiResponse({ status: 201, type: FloorResponseBody })
@ApiResponse({
status: 400,
Expand Down
4 changes: 4 additions & 0 deletions core/src/controllers/plants/plants.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { CreatePlantRequestBody } from './models/plant.request.body';
import { PlantsSearchRequestQuery } from './models/plant.request.query';
import { ErrorsRequestBody } from '../models/errors.response.body';
import { Response as Res } from 'express';
import { Roles } from '../../auth/roles/roles.decorator';
import { Role } from '../../auth/roles/role.enum';

@ApiBearerAuth()
@ApiTags('Plants')
Expand All @@ -30,6 +32,8 @@ export class PlantsController {
constructor(private plantsService: PlantsService, @InjectMapper() private mapper: Mapper) {}

@Post()
@Roles(Role.ADMIN)
@ApiResponse({ status: 403, description: 'Forbidden' })
@ApiResponse({ status: 201, type: PlantResponseBody })
@ApiResponse({
status: 400,
Expand Down
4 changes: 4 additions & 0 deletions core/src/controllers/profiles/models/mapper.profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export class ProfileMapperProfiles extends AutomapperProfile {
(d) => d.username,
mapFrom((s) => s.username),
),
forMember(
(d) => d.roles,
mapFrom((s) => s.roles),
),
);
};
}
Expand Down
4 changes: 4 additions & 0 deletions core/src/controllers/profiles/models/profile.response.body.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { Role } from '../../../auth/roles/role.enum';

export class ProfileResponseBody {
@ApiProperty()
id: string;

@ApiProperty()
username: string;

@ApiProperty({ isArray: true, enum: Role })
roles: Role[];
}
4 changes: 4 additions & 0 deletions core/src/controllers/varieties/varieties.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { CreateVarietyRequestBody } from './models/variety.request.body';
import { VarietiesSearchRequestQuery } from './models/variety.request.query';
import mongoose from 'mongoose';
import { ErrorsRequestBody } from '../models/errors.response.body';
import { Roles } from '../../auth/roles/roles.decorator';
import { Role } from '../../auth/roles/role.enum';

@ApiBearerAuth()
@ApiTags('Varieties')
Expand All @@ -20,6 +22,8 @@ export class VarietiesController {
constructor(private plantsService: VarietiesService, @InjectMapper() private mapper: Mapper) {}

@Post()
@Roles(Role.ADMIN)
@ApiResponse({ status: 403, description: 'Forbidden' })
@ApiResponse({ status: 201, type: VarietyResponseBody })
@ApiResponse({
status: 400,
Expand Down
4 changes: 4 additions & 0 deletions core/src/users/models/user.entity.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import mongoose, { Document } from 'mongoose';
import { Role } from '../../auth/roles/role.enum';

export type UserDocument = User & Document;

Expand All @@ -15,6 +16,9 @@ export class User {

@Prop({ required: true })
password: string;

@Prop({ required: true })
roles: Role[];
}

export const UserSchema = SchemaFactory.createForClass(User);

0 comments on commit dabc7c8

Please sign in to comment.