Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(permissions): role based access control #23

Merged
merged 1 commit into from
Jun 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);