InteractiveFrameworks

Accounts and roles

Give the shelter its people: accounts with hashed passwords in the database, sign-in with a JWT, a guard in front of every route with a few marked public, staff-only changes to the cats, adoptions made as whoever is signed in, and the database's own refusals turned into answers a client can use.

What you'll learn

  • Store users with bcrypt hashes, at a cost read from configuration, and seed a staff account on the first start
  • Sign users in with a JWT whose secret comes from configuration, and protect every route with a global guard that a @Public() decorator opens
  • Restrict routes by role with a second global guard reading @Roles() metadata
  • Take the acting user from the token rather than the request body, and map a database's unique-constraint error to a 409 with an exception filter

Right now anyone can add a cat, delete one, or adopt Tom in someone else's name by typing it into the body. The shelter needs to know who is asking, and to let staff do what adopters may not. This lesson adds the people: accounts, sign-in, and rules about who may call what. The Security course built each piece alone; here they meet a database, configuration and an API that already exists, and the interesting work is where they touch.

Accounts in the database

A User entity holds a username no two users share (@Column({ unique: true })), a bcrypt hash of the password and a role, staff or adopter. The hash's cost comes from .env (BCRYPT_ROUNDS): 10 or more in production, 4 here so the exercise stays quick, and the difference is one line of configuration rather than code. The first staff account is created by the service on its first start, with its password from .env, never written in code.

Sign-in, and a guard for everything

Login checks the password against the hash and signs a JWT whose payload names the user: { sub, username, role }. The secret comes from configuration, which means the JWT module must wait for it:

JwtModule.registerAsync({
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({ secret: config.getOrThrow('JWT_SECRET'), signOptions: { expiresIn: '1h' } }),
}),

Protecting each route by hand is how one gets forgotten. Instead, a guard registered with APP_GUARD runs before every route in the app, verifies the bearer token, and puts its payload on request.user; routes anyone may call say so with a @Public() decorator the guard reads through Reflector. Forgetting @Public() locks a route; forgetting a guard would have opened one, and the first mistake is the one you want to be possible.

A second global guard reads @Roles('staff') and compares it with request.user.role. Global guards run in the order they are registered, so authentication comes first and roles can rely on request.user.

Who is acting comes from the token

Lesson 2's adoption took the adopter's name from the body. With accounts, that is a hole: the body says whatever the client wants. The adopter is now the token's user, read from request.user, and the body carries nothing.

The database's refusals

Registering a taken username fails in the database: QueryFailedError: UNIQUE constraint failed: user.username, which Nest answers with a 500. The client did nothing the server could not handle; it chose a name that exists. Checking first with a query is a race between two registrations; the constraint is the real guard, and its error is what to translate. An exception filter catches QueryFailedError and turns a unique violation into a 409 naming the column:

@Catch(QueryFailedError)
export class QueryFailedFilter extends BaseExceptionFilter {
  catch(exception: QueryFailedError, host: ArgumentsHost) {
    // a unique violation: super.catch(new ConflictException(...), host)
  }
}

Every other database error is the service's fault: the filter logs it and answers a 500 that says the database failed, without its message, which names tables and columns. Registered with APP_FILTER, it covers the whole app.

Your task

.env now has JWT_SECRET, BCRYPT_ROUNDS and STAFF_PASSWORD.

  1. Users in the database, as above; the staff account sam is created on the first start.
  2. POST /auth/register makes an adopter and answers { id, username, role }; POST /auth/login answers { access_token } with status 200, or 401; GET /auth/me answers the token's user. Credentials are validated: a username of 2 to 30 characters, a password of 8 to 72.
  3. Every route needs a valid token except health, the two cat reads, register and login.
  4. Only staff create, update and remove cats, and read every adoption; GET /adoptions/mine is the signed-in user's.
  5. The adopter is the signed-in user.
  6. A taken username is a 409 username is already taken; any other database error a logged 500 The database could not complete the request.

When it fails

  • Every route is 401, health included: the global guard runs, and nothing is marked @Public().
  • secretOrPrivateKey must have a value at sign-in: the JWT module was registered before configuration was read; use registerAsync with ConfigService.
  • A 500 for a taken username, with UNIQUE constraint failed: user.username in the console: the filter is not registered, or does not catch QueryFailedError.
  • An adoption fails with a 400, or is made in whatever name the body gives: it still reads adopter from the body instead of request.user.

Remember

  • Secrets and costs live in configuration; the first staff account is seeded, not coded.
  • One global guard authenticates everything; @Public() opens a route on purpose.
  • Roles are a second guard, after authentication; the actor comes from the token.
  • Let the database enforce uniqueness, and translate its refusal into a 409.
Stuck? Show a hint

AuthModule: JwtModule.registerAsync({ inject: [ConfigService], useFactory: (config) => ({ secret: config.getOrThrow('JWT_SECRET'), signOptions: { expiresIn: '1h' } }) }), and { provide: APP_GUARD, useClass: AuthGuard } then RolesGuard. User: @Column({ unique: true }) username. UsersService.create: bcrypt.hash(password, Number(config.getOrThrow('BCRYPT_ROUNDS'))). QueryFailedFilter: @Catch(QueryFailedError), extends BaseExceptionFilter, /UNIQUE constraint failed: \w+\.(\w+)/ to a ConflictException, else super.catch(exception, host); registered with APP_FILTER. @Public() on health, the reads, register and login; @Roles('staff') on the cats' writes and the full adoptions list.