Skip to content

Commit

Permalink
backend: Fix JWT token authentication in Controllers
Browse files Browse the repository at this point in the history
  • Loading branch information
farnoux committed Nov 25, 2024
1 parent f5313cc commit 2529f8c
Show file tree
Hide file tree
Showing 6 changed files with 128 additions and 103 deletions.
57 changes: 33 additions & 24 deletions backend/src/auth/guards/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ import BackendConfigurationService from '../../config/configuration.service';
import { AllowAnonymousAccess } from '../decorators/allow-anonymous-access.decorator';
import { AllowPublicAccess } from '../decorators/allow-public-access.decorator';
import {
AuthenticatedUser,
AuthJwtPayload,
isAnonymousJwt,
jwtToAuthenticatedUser,
AuthUser,
isAnonymousUser,
isAuthenticatedUser,
isServiceRoleUser,
jwtToUser,
} from '../models/auth.models';
import SupabaseService from '../../common/services/supabase.service';

export const TOKEN_QUERY_PARAM = 'token';
export const REQUEST_JWT_PAYLOAD_PARAM = 'jwt-payload';
Expand All @@ -29,7 +30,6 @@ export class AuthGuard implements CanActivate {

constructor(
private jwtService: JwtService,
private supabase: SupabaseService,
private reflector: Reflector,
private backendConfigurationService: BackendConfigurationService
) {}
Expand All @@ -52,7 +52,8 @@ export class AuthGuard implements CanActivate {
throw new UnauthorizedException();
}

let jwtPayload;
// Validate JWT token and extract payload
let jwtPayload: AuthJwtPayload;
try {
jwtPayload = await this.jwtService.verifyAsync<AuthJwtPayload>(jwtToken, {
secret: this.backendConfigurationService.get('SUPABASE_JWT_SECRET'),
Expand All @@ -62,7 +63,30 @@ export class AuthGuard implements CanActivate {
throw new UnauthorizedException();
}

if (isAnonymousJwt(jwtPayload)) {
// Convert JWT payload to user
let user: AuthUser;
try {
user = jwtToUser(jwtPayload);
} catch (err) {
this.logger.error(`Failed to convert token: ${getErrorMessage(err)}`);
throw new UnauthorizedException();
}

// 💡 We're assigning the user to the request object here so that we can access it in our route handlers
// @ts-expect-error force attach a new property to the request object
request[REQUEST_JWT_PAYLOAD_PARAM] = user;

if (isAuthenticatedUser(user)) {
this.logger.log(`Authenticated user is allowed`);
return true;
}

if (isServiceRoleUser(user)) {
this.logger.log(`Service role user is allowed`);
return true;
}

if (isAnonymousUser(user)) {
const allowAnonymousAccess = this.reflector.get(
AllowAnonymousAccess,
context.getHandler()
Expand All @@ -77,23 +101,8 @@ export class AuthGuard implements CanActivate {
throw new UnauthorizedException();
}

// Else user is authenticated
// const { data } = await this.supabase.client.auth.getUser(jwtToken);

let authenticatedUser: AuthenticatedUser;
try {
authenticatedUser = jwtToAuthenticatedUser(jwtPayload);
this.logger.log(`Token validated for user ${authenticatedUser.id}`);
} catch (err) {
this.logger.error(`Failed to convert token: ${getErrorMessage(err)}`);
throw new UnauthorizedException();
}

// 💡 We're assigning the payload to the request object here so that we can access it in our route handlers
// @ts-expect-error force attach a new property to the request object
request[REQUEST_JWT_PAYLOAD_PARAM] = authenticatedUser;

return true;
this.logger.error(`Unknown user is not allowed`);
throw new UnauthorizedException();
}

private extractTokenFromRequest(request: Request): string | undefined {
Expand Down
76 changes: 44 additions & 32 deletions backend/src/auth/models/auth.models.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { User as SupabaseUser } from '@supabase/supabase-js';
import { JwtPayload } from 'jsonwebtoken';

export enum AuthRole {
Expand All @@ -7,54 +6,67 @@ export enum AuthRole {
ANON = 'anon', // Anonymous
}

export type User = Pick<SupabaseUser, 'id' | 'role' | 'is_anonymous'>;
// export type User = Pick<SupabaseUser, 'id' | 'role' | 'is_anonymous'>;

export interface AnonymousUser extends User {
role: AuthRole.ANON;
is_anonymous: true;
export interface AuthUser<Role extends AuthRole = AuthRole> {
id: Role extends AuthRole.AUTHENTICATED ? string : null;
role: Role;
isAnonymous: Role extends AuthRole.AUTHENTICATED ? false : true;
}

export interface AuthenticatedUser extends User {
role: AuthRole.AUTHENTICATED;
is_anonymous: false;
export type AnonymousUser = AuthUser<AuthRole.ANON>;
export type AuthenticatedUser = AuthUser<AuthRole.AUTHENTICATED>;
export type ServiceRoleUser = AuthUser<AuthRole.SERVICE_ROLE>;

export function isAnonymousUser(user: AuthUser | null): user is AnonymousUser {
return user?.role === AuthRole.ANON && user.isAnonymous === true;
}

export function isAnonymousUser(user: User | null): user is AnonymousUser {
return user?.role === AuthRole.ANON && user.is_anonymous === true;
export function isServiceRoleUser(
user: AuthUser | null
): user is AnonymousUser {
return user?.role === AuthRole.SERVICE_ROLE && user.isAnonymous === true;
}

export function isAuthenticatedUser(
user: User | null
user: AuthUser | null
): user is AuthenticatedUser {
return user?.role === AuthRole.AUTHENTICATED && user.is_anonymous === false;
return user?.role === AuthRole.AUTHENTICATED && user.isAnonymous === false;
}

export interface AuthJwtPayload extends JwtPayload {
role: AuthRole;
export interface AuthJwtPayload<Role extends AuthRole = AuthRole>
extends JwtPayload {
role: Role;
}

export interface AnonymousJwtPayload extends AuthJwtPayload {
role: AuthRole.ANON;
}
export function jwtToUser(jwt: AuthJwtPayload): AuthUser {
if (jwt.role === AuthRole.AUTHENTICATED) {
if (jwt.sub === undefined) {
throw new Error(`JWT sub claim is missing: ${JSON.stringify(jwt)}`);
}

export function isAnonymousJwt(
jwt: AuthJwtPayload
): jwt is AnonymousJwtPayload {
return jwt.role === AuthRole.ANON;
}
return {
id: jwt.sub,
role: AuthRole.AUTHENTICATED,
isAnonymous: false,
};
}

export function jwtToAuthenticatedUser(jwt: AuthJwtPayload): AuthenticatedUser {
if (jwt.role !== AuthRole.AUTHENTICATED) {
throw new Error(`JWT role is invalid: ${jwt.role}`);
if (jwt.role === AuthRole.ANON) {
return {
id: null,
role: AuthRole.ANON,
isAnonymous: true,
};
}

if (jwt.sub === undefined) {
throw new Error('JWT sub claim is missing');
if (jwt.role === AuthRole.SERVICE_ROLE) {
return {
id: null,
role: AuthRole.SERVICE_ROLE,
isAnonymous: true,
};
}

return {
id: jwt.sub,
role: AuthRole.AUTHENTICATED,
is_anonymous: false,
};
throw new Error(`JWT role is invalid: ${JSON.stringify(jwt)}`);
}
78 changes: 38 additions & 40 deletions backend/src/auth/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import DatabaseService from '../../common/services/database.service';
import {
AuthenticatedUser,
AuthRole,
AuthUser,
isAuthenticatedUser,
User,
isServiceRoleUser,
} from '../models/auth.models';
import {
NiveauAcces,
Expand Down Expand Up @@ -102,60 +103,60 @@ export class AuthService {
}

async verifieAccesAuxCollectivites(
tokenInfo: AuthenticatedUser,
user: AuthUser,
collectiviteIds: number[],
niveauAccessMinimum: NiveauAcces,
doNotThrow?: boolean
): Promise<boolean> {
let droits: UtilisateurDroitType[] = [];
const userId = tokenInfo.id;
if (isAuthenticatedUser(tokenInfo)) {
droits = await this.getDroitsUtilisateur(userId, collectiviteIds);

if (isAuthenticatedUser(user)) {
droits = await this.getDroitsUtilisateur(user.id, collectiviteIds);
}

const authorise = this.aDroitsSuffisants(
tokenInfo.role,
user.role,
droits,
collectiviteIds,
niveauAccessMinimum
);

if (!authorise && !doNotThrow) {
throw new UnauthorizedException(`Droits insuffisants`);
}

return authorise;
}

/**
* Vérifie si l'utilisateur a un rôle support
* @param tokenInfo token de l'utilisateur
* @param user token de l'utilisateur
* @return vrai si l'utilisateur a un rôle support
*/
async estSupport(tokenInfo: AuthenticatedUser): Promise<boolean> {
const userId = tokenInfo.id;
if (tokenInfo.role === AuthRole.AUTHENTICATED && userId) {
async estSupport(user: AuthUser): Promise<boolean> {
if (isAuthenticatedUser(user)) {
const result = await this.databaseService.db
.select()
.from(utilisateurSupportTable)
.where(eq(utilisateurSupportTable.userId, userId));
.where(eq(utilisateurSupportTable.userId, user.id));
return result[0].support || false;
}

return false;
}

/**
* Vérifie si l'utilisateur est vérifié
* @param tokenInfo token de l'utilisateur
* @param tokenInfo utilisateur authentifié
* @return vrai si l'utilisateur est vérifié
*/
async estVerifie(tokenInfo: AuthenticatedUser): Promise<boolean> {
const userId = tokenInfo.id;
if (tokenInfo.role === AuthRole.AUTHENTICATED && userId) {
const result = await this.databaseService.db
.select()
.from(utilisateurVerifieTable)
.where(eq(utilisateurVerifieTable.userId, userId));
return result[0].verifie || false;
}
return false;
async estVerifie(user: AuthenticatedUser): Promise<boolean> {
const result = await this.databaseService.db
.select()
.from(utilisateurVerifieTable)
.where(eq(utilisateurVerifieTable.userId, user.id));

return result[0].verifie || false;
}

/**
Expand All @@ -166,11 +167,11 @@ export class AuthService {
* @param doNotThrow vrai pour ne pas générer une exception
*/
async verifieAccesRestreintCollectivite(
user: User,
user: AuthUser,
collectiviteId: number,
doNotThrow?: boolean
): Promise<boolean> {
if (user.role === AuthRole.SERVICE_ROLE) {
if (isServiceRoleUser(user)) {
this.logger.log(
`Rôle de service détecté, accès autorisé à toutes les collectivités`
);
Expand Down Expand Up @@ -216,26 +217,23 @@ export class AuthService {
/**
* Vérifie que l'utilisateur est un auditeur de la collectivité
* TODO à modifier avec les tables liées aux labellisations
* @param tokenInfo token de l'utilisateur
* @param user utilisateur authentifié
* @param collectiviteId identifiant de la collectivité
*/
async estAuditeur(
tokenInfo: AuthenticatedUser,
user: AuthenticatedUser,
collectiviteId: number
): Promise<boolean> {
const userId = tokenInfo.id;
if (tokenInfo.role === AuthRole.AUTHENTICATED && userId) {
const result = await this.databaseService.db.execute(
sql`SELECT *
FROM audit_auditeur aa
JOIN labellisation.audit a ON aa.audit_id = a.id
WHERE a.date_debut IS NOT NULL
AND a.clos IS FALSE
AND a.collectivite_id = ${collectiviteId}
AND aa.auditeur = ${userId}`
);
return result?.length > 0 || false;
}
return false;
const result = await this.databaseService.db.execute(
sql`SELECT *
FROM audit_auditeur aa
JOIN labellisation.audit a ON aa.audit_id = a.id
WHERE a.date_debut IS NOT NULL
AND a.clos IS FALSE
AND a.collectivite_id = ${collectiviteId}
AND aa.auditeur = ${user.id}`
);

return result?.length > 0 || false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { isNil, partition } from 'es-toolkit';
import * as _ from 'lodash';
import slugify from 'slugify';
import { AuthenticatedUser } from '../../auth/models/auth.models';
import { EpciType } from '../../collectivites/models/epci.table';
import GroupementsService from '../../collectivites/services/groupements.service';
import ConfigurationService from '../../config/configuration.service';
Expand All @@ -24,7 +25,6 @@ import { VerificationTrajectoireStatus } from '../models/verification-trajectoir
import IndicateursService from './indicateurs.service';
import IndicateurSourcesService from './indicateurSources.service';
import TrajectoiresDataService from './trajectoires-data.service';
import { AuthenticatedUser } from '../../auth/models/auth.models';

@Injectable()
export default class TrajectoiresSpreadsheetService {
Expand Down
12 changes: 10 additions & 2 deletions backend/src/trpc/trpc.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { SupabaseClient } from '@supabase/supabase-js';
import { initTRPC, TRPCError } from '@trpc/server';
import { CreateExpressContextOptions } from '@trpc/server/adapters/express';
import {
AuthUser,
isAnonymousUser,
isAuthenticatedUser,
User,
} from '../auth/models/auth.models';

@Injectable()
Expand Down Expand Up @@ -101,8 +101,16 @@ export async function createContext(
data: { user },
} = await supabase.auth.getUser(supabaseToken);

if (!user) {
return { user: null };
}

return {
user: user as User,
user: {
id: user.id ?? null,
role: user.role,
isAnonymous: user.is_anonymous,
} as AuthUser,
};
} catch (error) {
return { user: null };
Expand Down
Loading

0 comments on commit 2529f8c

Please sign in to comment.