193 lines
9.9 KiB
Markdown
193 lines
9.9 KiB
Markdown
# Auth Profile
|
|
|
|
## Purpose
|
|
|
|
The auth subsystem owns sign-in, signup, the current-user profile contract
|
|
(`GET /api/auth/me`), password reset / email verification, OAuth (Google)
|
|
sign-in, and the permission enforcement model. This document covers
|
|
the profile and permission concerns; the HttpOnly cookie session transport
|
|
(access/refresh tokens, rotation, CSRF/origin, sign-out) is documented in
|
|
`backend/docs/cookie-auth.md`.
|
|
|
|
The profile response must not expose passwords, verification tokens, reset
|
|
tokens, or raw Sequelize model objects.
|
|
|
|
## Slice Files (by layer)
|
|
|
|
- Route: `src/routes/auth.ts` (thin wiring; mounted at `/api/auth` in
|
|
`src/index.ts`). `/me`, `/password-update`, `/send-email-address-verification-email`,
|
|
and `/profile` are guarded by `passport.authenticate('jwt', { session: false })`.
|
|
- Controller: `src/api/controllers/auth.controller.ts` (custom — not the CRUD
|
|
factory).
|
|
- Service (BLL): `src/services/auth.ts` (class `Auth`, default export
|
|
`AuthService`) with DTO shapes in `src/services/auth.types.ts`.
|
|
- Passport strategies: `src/auth/auth.ts` (JWT via `passport-jwt`; Google via
|
|
the maintained, typed **`passport-google-oauth20`**). OAuth is wired for future
|
|
use and is not surfaced in the current UI.
|
|
- Cookie helpers: `src/auth/cookies.ts` (used for the session transport;
|
|
see `cookie-auth.md`).
|
|
- Permission middleware: `src/middlewares/check-permissions.ts`
|
|
(`checkPermissions`, `checkCrudPermissions`).
|
|
- Origin middleware: `src/middlewares/csrf-origin.ts` (see `cookie-auth.md`).
|
|
- Repositories (DAL): `src/db/api/users.ts` (`UsersDBApi`),
|
|
`src/db/api/auth_refresh_tokens.ts` (`AuthRefreshTokensDBApi`),
|
|
`src/db/api/roles.ts` (`RolesDBApi`, used by the permission middleware).
|
|
- Models: `src/db/models/users.ts`, `src/db/models/auth_refresh_tokens.ts`,
|
|
plus the `roles`, `permissions`, `organizations`, and `campuses` models
|
|
joined for the profile.
|
|
- Shared used: `shared/config` (auth/cookie/OAuth config), `shared/jwt`
|
|
(`jwtSign`), `shared/constants/auth.ts`, `shared/constants/roles.ts`
|
|
(role definitions, scopes, names), `shared/errors/*` (`ForbiddenError`,
|
|
`ValidationError`), `services/email/*` (verification / reset / invitation
|
|
emails).
|
|
|
|
## API
|
|
|
|
Base path `/api/auth` (mounted in `src/index.ts`).
|
|
|
|
Profile / account endpoints:
|
|
|
|
- `GET /api/auth/me` (JWT-authenticated) -> `200` the current user profile
|
|
payload (see Data Contract). Returns `ForbiddenError` when there is no
|
|
current user.
|
|
- `POST /api/auth/signup` -> `200` the current user profile. Body:
|
|
`{ email, password, organizationId }`. Creates a session and sets cookies.
|
|
- `PUT /api/auth/profile` (JWT-authenticated) -> `200` `true`. Body:
|
|
`{ profile }` (passed to `UsersDBApi.update`).
|
|
- `PUT /api/auth/password-update` (JWT-authenticated) -> `200` the updated
|
|
user. Body: `{ currentPassword, newPassword }`.
|
|
- `PUT /api/auth/password-reset` -> `200` the updated user. Body:
|
|
`{ token, password }`.
|
|
- `PUT /api/auth/verify-email` -> `200` result of marking the email verified.
|
|
Body: `{ token }`.
|
|
- `POST /api/auth/send-password-reset-email` -> `200` `true`. Body: `{ email }`.
|
|
The reset link host is derived from the request referer.
|
|
- `POST /api/auth/send-email-address-verification-email` (JWT-authenticated)
|
|
-> `200` `true`. Sends a verification email to the current user's address.
|
|
- `GET /api/auth/email-configured` -> `200` boolean (`EmailSender.isConfigured`).
|
|
|
|
Sign-in (covered in detail in `cookie-auth.md`):
|
|
|
|
- `POST /api/auth/signin/local` -> `200` the current user profile.
|
|
|
|
OAuth endpoints:
|
|
|
|
- `GET /api/auth/signin/google` -> redirects to Google
|
|
(`passport.authenticate('google', { scope: ['profile', 'email'], state })`).
|
|
- `GET /api/auth/signin/google/callback` (Passport `google`,
|
|
`failureRedirect: '/login'`) -> sets session cookies and redirects to
|
|
`config.uiUrl` (no token query parameters).
|
|
|
|
OAuth users are resolved by `db.users.findOrCreate({ where: { email, provider } })`
|
|
in `src/auth/auth.ts`; the Google email comes from the typed
|
|
`profile.emails[0].value`.
|
|
|
|
## Access Rules
|
|
|
|
- JWT routes are protected by `passport.authenticate('jwt', { session: false })`,
|
|
which extracts the access token from the HttpOnly access cookie
|
|
(`cookies.extractAccessCookie`) and loads the user by email. A disabled user
|
|
is rejected by the strategy (`done(new Error(...))`).
|
|
- Permission enforcement (`src/middlewares/check-permissions.ts`):
|
|
- `checkPermissions(permission)` allows the request if any of:
|
|
1. read-only self-access bypass — `currentUser.id` equals `req.params.id`
|
|
on a `GET` request;
|
|
2. super-admin bypass — the user's role is `super_admin`, which bypasses
|
|
standard permission checks except personal workflow permissions listed in
|
|
`GLOBAL_BYPASS_EXCLUDED_PERMISSIONS`;
|
|
3. the user's `custom_permissions_filter` does not exclude `permission`;
|
|
4. the user's `custom_permissions` include `permission`;
|
|
5. the effective role's permissions include `permission`. The effective
|
|
role is the user's `app_role`, or the cached seeded `guest` role
|
|
(`ROLE_NAMES.GUEST`) when there is no assigned role. The `guest` role is
|
|
fetched once at module load and cached.
|
|
- A denied request is passed `new ValidationError('auth.forbidden')`.
|
|
- `checkCrudPermissions(name)` derives the permission as
|
|
`${METHOD}_${ENTITY}` from the HTTP method and entity name, where the
|
|
method maps `POST->CREATE`, `GET->READ`, `PUT->UPDATE`, `PATCH->UPDATE`,
|
|
`DELETE->DELETE` and `ENTITY` is `name.toUpperCase()`. It then delegates to
|
|
`checkPermissions`. This middleware is applied per generic-CRUD router via
|
|
`router.use(permissions.checkCrudPermissions(permission))` in
|
|
`src/api/http/crud-router.ts`; the auth routes themselves do not use it.
|
|
|
|
## Tenant Scope
|
|
|
|
- The profile is loaded for the authenticated user only
|
|
(`UsersDBApi.findProfileById(currentUser.id)`), so it reflects that user's
|
|
own organization, role, and direct tenant scope.
|
|
- `signup` accepts an `organizationId` and assigns it to the created user.
|
|
- Tenant filtering for other entities is enforced elsewhere (CRUD repositories
|
|
scope by `currentUser.organizationId`); the auth profile endpoints do not
|
|
add tenant filtering beyond loading the current user.
|
|
|
|
## Data Contract
|
|
|
|
`AuthService.currentUserProfile` returns (built from `findProfileById`):
|
|
|
|
- `id`, `email`, `name_prefix` (honorific title — `mr`/`ms`/`mrs`/`mx`/`dr`/`prof`, or `null`), `firstName`, `lastName`, `phoneNumber`
|
|
- `organizationId`
|
|
- `organizations` — `OrganizationDto` `{ id, name }` or `null`
|
|
- `app_role` — `RoleDto` `{ id, name, scope, globalAccess }` or `null`. `name`
|
|
is one of the first-class role names and `scope` is its scope
|
|
(`system` | `organization` | `school` | `campus` | `class` | `external` |
|
|
`guest`); the frontend
|
|
derives the UI role from `app_role.name`. There is no separate `productRole`.
|
|
- `campus` — `CampusDto` `{ id, name, code }` or `null`
|
|
- `campusId` — the user's direct campus scope id, else the campus DTO id, else `null`
|
|
- `permissions` — effective permission names: role permissions plus
|
|
`custom_permissions`, minus `custom_permissions_filter`.
|
|
|
|
Role model: roles are first-class persisted rows (`src/shared/constants/roles.ts`
|
|
`ROLE_DEFINITIONS` / `ROLE_NAMES`, seeded by
|
|
`db/seeders/20200430130760-user-roles.ts`). Each role has a `scope` and, for the
|
|
two system roles, `globalAccess: true`. The preset permission matrix grants
|
|
`owner` / `superintendent` / `principal` / `director` every permission,
|
|
`registrar` / `office_manager` / `teacher` / `support_staff` read-only entity
|
|
permissions, and `student` / `guardian` / `guest` no entity CRUD permissions;
|
|
`guardian` still receives the parent-communication product permission.
|
|
`system_admin` also receives explicit role-permission rows and is processed like
|
|
every other permission-based role; `globalAccess` still gives it platform-wide
|
|
tenant reach. Only `super_admin` keeps the standard permission bypass. Personal
|
|
workflow permissions (`READ_PARENT_COMM`, `ACK_POLICY`, `ZONE_CHECKIN`) are
|
|
excluded from that bypass and must be explicitly present. Per-user
|
|
`custom_permissions` extend a user's grants; `custom_permissions_filter`
|
|
removes specific permissions from the role grant.
|
|
|
|
Signup / signin behavior (`src/services/auth.ts`):
|
|
|
|
- `signin` throws `ValidationError` for `auth.userNotFound`, `auth.userDisabled`,
|
|
`auth.wrongPassword`, or `auth.userNotVerified`. When email is not configured
|
|
(`EmailSender.isConfigured` is false), `emailVerified` is treated as true.
|
|
- `signup` rehashes the password with `bcrypt` (`config.bcrypt.saltRounds`).
|
|
An existing disabled user raises `auth.userDisabled`; an existing enabled user
|
|
has its password updated; a new user is created via `UsersDBApi.createFromAuth`
|
|
(first name defaults to the email local-part, default role from
|
|
`config.roles.user`). A verification email is sent when email is configured.
|
|
- `passwordUpdate` requires a logged-in user, verifies the current password,
|
|
rejects reuse of the same password (`auth.passwordUpdate.samePassword`), and
|
|
stores the new bcrypt hash.
|
|
- `passwordReset` / `verifyEmail` look up the user by a non-expired
|
|
`passwordResetToken` / `emailVerificationToken` and raise the matching
|
|
`ValidationError` on an invalid token. Tokens are random hex of
|
|
`EMAIL_ACTION_TOKEN_BYTES` with TTL `EMAIL_ACTION_TOKEN_TTL_MS`.
|
|
|
|
## Behavior / Notes
|
|
|
|
- Profile loads use a single trimmed eager query (`UsersDBApi.findProfileById`)
|
|
selecting only the columns and relations the DTO reads.
|
|
- Auth tokens are never returned in response bodies or redirect URLs; OAuth
|
|
callbacks redirect to `config.uiUrl` with cookies only.
|
|
- `updateProfile` runs inside a Sequelize transaction.
|
|
|
|
## Tests
|
|
|
|
- `src/services/auth.test.ts` covers auth/profile service behavior.
|
|
- `src/api/controllers/auth.controller.test.ts` covers auth controller request
|
|
handling.
|
|
|
|
## Related
|
|
|
|
- Cookie session transport: `backend/docs/cookie-auth.md`.
|
|
- Frontend: `frontend/docs/auth-integration.md`.
|
|
- Role constants (definitions, scopes, names): `src/shared/constants/roles.ts`.
|