Originally posted on my blog: https://blog.merckx.fr/impersonation-with-nestjs/
Impersonation is the ability of users to act as other users. Usually it consists in administrators of a system having the ability to interact as standard users on the same system. Gitlab, for instance, provides this feature. It is also part of the feature to provide a way to revert the impersonation.
In this post, we present one approach to implement this feature and we illustrate principles with code from Nestjs.
Assumptions
We assume you already have some sort of user session implementation: a cookie that either directly contains information (client-side cookie, signed with a secret) about the current user or an identifier that links to such information in a store.
We also assume that a REST API that filters in data that belong to the user.
With Nestjs, we will assume that the request object contains a property called user
that contains the current information about the user. We will also assume that this property is filled in by a guard, based on the session. We will also assume that the session is implement with a client-side cookie that contains the current user identifier. We will also assume that TypeOrm is used to provide CRUD endpoints and that resources are filtered by user.
interface CustomRequest extends express.Request {
user: User;
}
interface CustomSession {
user {
id: string;
}
}
@Crud({
model: {
type: ...,
},
})
@CrudAuth({
property: 'user',
filter: (user: User) => {
return {
userId: user.id,
};
},
})
@Controller('entity')
export class MyEntityController implements CrudController<...> {
…
}
The last assumption is that the Nestjs application has a guard that checks whether the user is authenticated and that adds a user
property to the request object.
@Injectable()
export class IsAuthenticatedGuard implements CanActivate {
constructor(
@InjectRepository(User) private users: Repository<User>,
) {}
async canActivate(
context: ExecutionContext,
): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const session = request.session as CustomSession;
if (!session.user) {
return false;
}
request.user = await this.users.findOne({ where: { id: session.user.id }});
return true;
}
}
Principles
We are going to change as less code as possible but still make pretty explicit what happens. The main idea is to store two pieces of information in the user session:
- the actual logged in user
- the impersonated user
Therefore, we change the session structure to the following:
interface CustomSession {
loggedInUser: {
id: string;
}
impersonatedUser: {
id: string
}
}
We must know provide two API endpoints that enable the user to impersonate and to stop the impersonation.
The impersonation endpoints
@Get('impersonate')
removeImpersonation(@Session() session: CustomSession) {
session.impersonatedUser = session.loggedInUser;
}
@Get('users/:id/impersonate')
async impersonate(@Param('id') id: number, @Session() session: CustomSession) {
const user = await this.users.findOne({ where: { id }});
if (!user) {
throw new NotFoundException();
}
session.impersonatedUser = user;
}
Now, when logging in, we must fill in the two properties session.impersonatedUser
and session.loggedInUser
. Here is an example:
The modified login route
@Post('/login')
async login(@Session() session: CustomSession, @Body('username') username: string, @Body('password') password: string) {
session.impersonatedUser = null;
session.loggedInUser = null;
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException();
}
session.impersonatedUser = session.loggedInUser = this.serializeUser(user);
return session.loggedInUser;
}
Our authentication guard must fill in the request.user
with the impersonated user. Here is an example:
The modified authentication guard
async canActivate(
context: ExecutionContext,
): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const session = request.session as CustomSession;
if (!session.impersonatedUser) {
return false;
}
request.user = await this.users.findOne({ where: { id: session.impersonatedUser.id }});
return true;
}
Our authentication fills in the session with the logged in user and the impersonated user information. Our guard fills in the user property with the impersonated user from the user session. Our CRUD controllers can now filter data by user.
Let's walk through the mechanism.
- The user logs in. A
POST
request is sent to thelogin
endpoint. The session is filled in withimpersonatedUser
andloggedInUser
properties. - The user starts impersonating by reaching the
/admin/users/1234/impersonate
endpoint. Once reached, the session is modified: theimpersonatedUserId
is filled in with information about user1234
. - The user reaches a CRUD endpoint, let's say
/entity
. The authentication guard gets theimpersonatedUserId
property from the session and adds auser
property to the request object based on it. - TypeOrm filters the
entity
by the information stored in theuser
property of the request object. - The user stops the impersonation by reaching
/admin/impersonate
endpoint. The session is modified: theimpersonatedUserId
is filled in with theloggedInUserId
.
Do not forget to protect the impersonation endpoint!
That's it! With this approach, we make minimal changes to the code base and we enable a great feature: Impersonation.
KM
Photo by Felicia Buitenwerf on Unsplash
Top comments (0)