765 lines
25 KiB
Markdown
765 lines
25 KiB
Markdown
# Authentication System
|
|
|
|
Complete documentation for the Tour Builder Platform authentication system including local auth, OAuth, JWT tokens, and security features.
|
|
|
|
## Overview
|
|
|
|
The platform uses a multi-strategy authentication system built on:
|
|
- **Passport.js** - Authentication middleware with JWT, Google OAuth, and Microsoft OAuth strategies
|
|
- **JWT Tokens** - 6-hour expiration, Bearer token in Authorization header
|
|
- **bcrypt** - Password hashing with 12 salt rounds
|
|
- **Rate Limiting** - In-memory rate limiting for auth endpoints
|
|
|
|
## Architecture
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ Frontend │
|
|
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
|
|
│ │ Login Page │ │ authSlice.ts │ │ Axios Interceptors │ │
|
|
│ │ Register │ │ (Redux) │ │ - Token injection │ │
|
|
│ │ Forgot │ │ │ │ - 401 handling │ │
|
|
│ └─────────────┘ └──────────────┘ └────────────────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ Backend │
|
|
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
|
|
│ │ auth.js │ │ AuthService │ │ Passport.js Strategies │ │
|
|
│ │ (routes) │ │ (business) │ │ - JWT │ │
|
|
│ │ │ │ │ │ - Google OAuth │ │
|
|
│ │ │ │ │ │ - Microsoft OAuth │ │
|
|
│ └─────────────┘ └──────────────┘ └────────────────────────┘ │
|
|
│ │
|
|
│ ┌─────────────────────────────────────────────────────────────┐│
|
|
│ │ UsersDBApi ││
|
|
│ │ - Token generation (email verification, password reset) ││
|
|
│ │ - User CRUD operations ││
|
|
│ └─────────────────────────────────────────────────────────────┘│
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
## Passport.js Strategies
|
|
|
|
### JWT Strategy
|
|
|
|
**File:** `backend/src/auth/auth.ts`
|
|
|
|
```javascript
|
|
passport.use(new JWTstrategy({
|
|
passReqToCallback: true,
|
|
secretOrKey: config.secret_key,
|
|
jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken()
|
|
}, async (req, token, done) => {
|
|
// Validates token and checks if user is disabled
|
|
// Sets req.currentUser for authenticated requests
|
|
}));
|
|
```
|
|
|
|
| Property | Value |
|
|
|----------|-------|
|
|
| Token Location | `Authorization: Bearer {token}` header |
|
|
| Secret Key | `process.env.SECRET_KEY` |
|
|
| Token Expiration | 6 hours |
|
|
| Validation | Checks user exists and is not disabled |
|
|
|
|
### Google OAuth Strategy
|
|
|
|
```javascript
|
|
passport.use(new GoogleStrategy({
|
|
clientID: config.google.clientId,
|
|
clientSecret: config.google.clientSecret,
|
|
callbackURL: config.apiUrl + '/auth/signin/google/callback',
|
|
passReqToCallback: true
|
|
}));
|
|
```
|
|
|
|
| Property | Value |
|
|
|----------|-------|
|
|
| Scopes | `profile`, `email` (defined in routes when calling `passport.authenticate()`) |
|
|
| Callback URL | `/api/auth/signin/google/callback` |
|
|
| Auto-Verification | Social auth users are automatically `emailVerified: true` |
|
|
|
|
### Microsoft OAuth Strategy
|
|
|
|
```javascript
|
|
passport.use(new MicrosoftStrategy({
|
|
clientID: config.microsoft.clientId,
|
|
clientSecret: config.microsoft.clientSecret,
|
|
callbackURL: config.apiUrl + '/auth/signin/microsoft/callback',
|
|
passReqToCallback: true
|
|
}));
|
|
```
|
|
|
|
| Property | Value |
|
|
|----------|-------|
|
|
| Scopes | `https://graph.microsoft.com/user.read openid` (defined in routes when calling `passport.authenticate()`) |
|
|
| Callback URL | `/api/auth/signin/microsoft/callback` |
|
|
| Email Source | `profile._json.mail` or `profile._json.userPrincipalName` |
|
|
|
|
## API Endpoints
|
|
|
|
### Authentication Routes
|
|
|
|
| Method | Endpoint | Auth | Rate Limited | Description |
|
|
|--------|----------|------|--------------|-------------|
|
|
| POST | `/auth/signin/local` | No | Yes (10/15m) | Login with email/password |
|
|
| GET | `/auth/me` | JWT | No | Get current user info |
|
|
| PUT | `/auth/password-reset` | No | No | Reset password with token |
|
|
| PUT | `/auth/password-update` | JWT | No | Change password |
|
|
| POST | `/auth/send-password-reset-email` | No | Yes (5/1h) | Request reset email |
|
|
| POST | `/auth/send-email-address-verification-email` | JWT | No | Resend verification |
|
|
| PUT | `/auth/verify-email` | No | No | Verify email with token |
|
|
| PUT | `/auth/profile` | JWT | No | Update user profile |
|
|
| GET | `/auth/email-configured` | No | No | Check email config |
|
|
| GET | `/auth/signin/google` | No | No | Initiate Google OAuth |
|
|
| GET | `/auth/signin/google/callback` | No | No | Google OAuth callback |
|
|
| GET | `/auth/signin/microsoft` | No | No | Initiate Microsoft OAuth |
|
|
| GET | `/auth/signin/microsoft/callback` | No | No | Microsoft OAuth callback |
|
|
|
|
### File Endpoints (Public)
|
|
|
|
| Method | Endpoint | Auth | Description |
|
|
|--------|----------|------|-------------|
|
|
| GET | `/file/download` | No | Download file (backend proxy for local/GCloud) |
|
|
| POST | `/file/presign` | No | Generate presigned URLs for S3 direct downloads |
|
|
| POST | `/file/upload/:table/:field` | JWT | Legacy single-file upload |
|
|
| POST | `/file/upload-sessions/init` | JWT | Initialize chunked upload |
|
|
| PUT | `/file/upload-sessions/:id/chunks/:idx` | JWT | Upload chunk |
|
|
| GET | `/file/upload-sessions/:id` | JWT | Check upload session status |
|
|
| POST | `/file/upload-sessions/:id/finalize` | JWT | Finalize chunked upload |
|
|
|
|
**Note:** The download and presign endpoints are intentionally public to support runtime asset preloading without authentication. Access control for assets should be implemented at the storage level (S3 bucket policies) if needed.
|
|
|
|
### Endpoint Details
|
|
|
|
#### POST /auth/signin/local
|
|
|
|
**Request:**
|
|
```json
|
|
{
|
|
"email": "user@example.com",
|
|
"password": "password123"
|
|
}
|
|
```
|
|
|
|
**Response (Success 200):** JWT token string
|
|
|
|
**Error Codes (400):**
|
|
- `auth.userNotFound` - User doesn't exist
|
|
- `auth.userDisabled` - Account is disabled
|
|
- `auth.wrongPassword` - Incorrect password
|
|
- `auth.userNotVerified` - Email not verified
|
|
|
|
#### Self-Registration
|
|
|
|
Self-registration is disabled. The application does not expose
|
|
`POST /auth/signup` and the frontend does not provide a `/register` page. New
|
|
users are created by authorized staff through the Users flow and receive an
|
|
invitation/setup link.
|
|
|
|
#### GET /auth/me
|
|
|
|
**Headers:** `Authorization: Bearer {token}`
|
|
|
|
**Response (Success 200):**
|
|
```json
|
|
{
|
|
"id": "uuid",
|
|
"email": "user@example.com",
|
|
"firstName": "John",
|
|
"lastName": "Doe",
|
|
"emailVerified": true,
|
|
"disabled": false,
|
|
"provider": "local",
|
|
"app_role": { "id": "...", "name": "User" },
|
|
"custom_permissions": []
|
|
}
|
|
```
|
|
|
|
#### PUT /auth/verify-email
|
|
|
|
**Request:**
|
|
```json
|
|
{
|
|
"token": "email_verification_token_40_chars"
|
|
}
|
|
```
|
|
|
|
**Response (Success 200):** `true`
|
|
|
|
**Error Codes (400):**
|
|
- `auth.emailAddressVerificationEmail.invalidToken` - Invalid or expired token
|
|
|
|
#### PUT /auth/password-reset
|
|
|
|
**Request:**
|
|
```json
|
|
{
|
|
"token": "password_reset_token_40_chars",
|
|
"password": "newPassword123"
|
|
}
|
|
```
|
|
|
|
**Response (Success 200):** User object
|
|
|
|
**Error Codes (400):**
|
|
- `auth.passwordReset.invalidToken` - Invalid or expired token
|
|
|
|
#### PUT /auth/password-update
|
|
|
|
**Headers:** `Authorization: Bearer {token}`
|
|
|
|
**Request:**
|
|
```json
|
|
{
|
|
"currentPassword": "oldPassword123",
|
|
"newPassword": "newPassword123"
|
|
}
|
|
```
|
|
|
|
**Error Codes (400):**
|
|
- `auth.wrongPassword` - Current password incorrect
|
|
- `auth.passwordUpdate.samePassword` - New password same as old
|
|
|
|
## Rate Limiting
|
|
|
|
**File:** `backend/src/middlewares/rateLimiter.js`
|
|
|
|
| Endpoint | Limiter | Max Requests | Window | Key |
|
|
|----------|---------|--------------|--------|-----|
|
|
| `/auth/signin/local` | `authLimiter` | 10 | 15 minutes | IP address |
|
|
| `/auth/send-password-reset-email` | `passwordResetLimiter` | 5 | 1 hour | IP address |
|
|
|
|
**Implementation:**
|
|
```javascript
|
|
// backend/src/routes/auth.ts imports from centralized rate limiter
|
|
const {
|
|
authLimiter: signinLimiter,
|
|
passwordResetLimiter,
|
|
} = require('../middlewares/rateLimiter');
|
|
|
|
// Rate limiters are created with createRateLimiter factory
|
|
const authLimiter = createRateLimiter({
|
|
keyPrefix: 'auth',
|
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
max: 10,
|
|
message: 'Too many authentication attempts. Please try again later.',
|
|
skipFailedRequests: false, // Count failed attempts
|
|
});
|
|
```
|
|
|
|
- Uses centralized in-memory Map: `rateLimitStore`
|
|
- Key format: `${keyPrefix}:${req.ip}`
|
|
- Adds standard rate limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`)
|
|
- Returns 429 status with JSON response when exceeded
|
|
- Automatic cleanup of expired entries every 5 minutes
|
|
- Skips rate limiting in development for localhost
|
|
|
|
## User Model
|
|
|
|
**File:** `backend/src/db/models/users.js`
|
|
|
|
| Field | Type | Required | Default | Notes |
|
|
|-------|------|----------|---------|-------|
|
|
| id | UUID | Yes | UUIDV4 | Primary key |
|
|
| email | TEXT | Yes | - | Unique, validated as email |
|
|
| password | TEXT | Yes | - | bcrypt hashed |
|
|
| firstName | TEXT | No | null | Trimmed on save |
|
|
| lastName | TEXT | No | null | Trimmed on save |
|
|
| phoneNumber | TEXT | No | null | |
|
|
| disabled | BOOLEAN | No | false | Account status |
|
|
| emailVerified | BOOLEAN | No | false | Verification status |
|
|
| emailVerificationToken | TEXT | No | null | 40-char hex |
|
|
| emailVerificationTokenExpiresAt | DATE | No | null | 24h expiry |
|
|
| passwordResetToken | TEXT | No | null | 40-char hex |
|
|
| passwordResetTokenExpiresAt | DATE | No | null | 24h expiry |
|
|
| provider | TEXT | No | 'local' | local/google/microsoft |
|
|
| app_roleId | UUID | No | null | FK to roles |
|
|
| importHash | STRING(255) | No | null | Unique, for CSV deduplication |
|
|
|
|
**Note:** The `authenticationUid` field is set dynamically during `createFromAuth()` and `updatePassword()` operations (set to user ID after creation).
|
|
|
|
### Model Hooks
|
|
|
|
```javascript
|
|
users.beforeCreate((users) => {
|
|
// Trim email, firstName, lastName
|
|
if (users.provider !== 'local') {
|
|
users.emailVerified = true; // Auto-verify social auth
|
|
if (!users.password) {
|
|
// Generate random password for OAuth users
|
|
users.password = bcrypt.hashSync(randomPassword, 12);
|
|
}
|
|
}
|
|
});
|
|
```
|
|
|
|
## Token Generation
|
|
|
|
**File:** `backend/src/db/api/users.js`
|
|
|
|
### Email Verification Token
|
|
```javascript
|
|
const token = crypto.randomBytes(20).toString('hex'); // 40-char hex
|
|
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
```
|
|
|
|
### Password Reset Token
|
|
```javascript
|
|
const token = crypto.randomBytes(20).toString('hex'); // 40-char hex
|
|
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
```
|
|
|
|
### JWT Token
|
|
**File:** `backend/src/helpers.js`
|
|
|
|
```javascript
|
|
static jwtSign(data) {
|
|
return jwt.sign(data, config.secret_key, { expiresIn: '6h' });
|
|
}
|
|
```
|
|
|
|
Payload structure:
|
|
```json
|
|
{
|
|
"user": {
|
|
"id": "uuid",
|
|
"email": "user@example.com"
|
|
}
|
|
}
|
|
```
|
|
|
|
## Frontend Implementation
|
|
|
|
### Auth Slice (Redux)
|
|
|
|
**File:** `frontend/src/stores/authSlice.ts`
|
|
|
|
```typescript
|
|
interface MainState {
|
|
isFetching: boolean;
|
|
errorMessage: string;
|
|
currentUser: any;
|
|
token: string;
|
|
notify: any; // Contains showNotification, textNotification, typeNotification
|
|
}
|
|
```
|
|
|
|
### Async Thunks
|
|
|
|
**loginUser:**
|
|
```typescript
|
|
export const loginUser = createAsyncThunk(
|
|
'auth/loginUser',
|
|
async (creds: Record<string, string>, { rejectWithValue }) => {
|
|
const response = await axios.post('auth/signin/local', creds);
|
|
return response.data; // JWT token
|
|
},
|
|
);
|
|
```
|
|
|
|
Expected `creds` object: `{ email: string, password: string }`
|
|
|
|
**findMe:**
|
|
```typescript
|
|
export const findMe = createAsyncThunk('auth/findMe', async () => {
|
|
const response = await axios.get('auth/me');
|
|
return response.data; // Current user
|
|
});
|
|
```
|
|
|
|
### Token Storage
|
|
|
|
On successful login:
|
|
```typescript
|
|
builder.addCase(loginUser.fulfilled, (state, action) => {
|
|
const token = action.payload;
|
|
const user = jwt.decode(token);
|
|
|
|
// Store in both storages
|
|
sessionStorage.setItem('token', token);
|
|
sessionStorage.setItem('user', JSON.stringify(user));
|
|
localStorage.setItem('token', token);
|
|
localStorage.setItem('user', JSON.stringify(user));
|
|
|
|
// Set default header
|
|
axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;
|
|
});
|
|
```
|
|
|
|
On logout (reducer action in authSlice):
|
|
```typescript
|
|
logoutUser: (state) => {
|
|
sessionStorage.removeItem('token');
|
|
sessionStorage.removeItem('user');
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
axios.defaults.headers.common['Authorization'] = ''; // Set to empty in reducer
|
|
state.currentUser = null;
|
|
state.token = '';
|
|
}
|
|
```
|
|
|
|
### Axios Interceptors
|
|
|
|
**File:** `frontend/src/pages/_app.tsx`
|
|
|
|
**Request Interceptor (Token Injection):**
|
|
```typescript
|
|
axios.interceptors.request.use((config) => {
|
|
if (typeof window !== 'undefined') {
|
|
const token = sessionStorage.getItem('token') || localStorage.getItem('token');
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
}
|
|
return config;
|
|
});
|
|
```
|
|
|
|
**Response Interceptor (401 Handling + Presigned URL Failure Detection):**
|
|
```typescript
|
|
axios.interceptors.response.use(
|
|
(response) => response,
|
|
(error) => {
|
|
if (typeof window !== 'undefined') {
|
|
const status = error?.response?.status;
|
|
const requestUrl = `${error?.config?.url || ''}`;
|
|
const isLoginRequest =
|
|
requestUrl.includes('/auth/signin/local') ||
|
|
requestUrl.includes('auth/signin/local');
|
|
|
|
// Detect presigned S3 URL failures (CORS not configured)
|
|
// Network errors (status 0) or CORS errors typically indicate S3 CORS issues
|
|
if (
|
|
isPresignedS3Url(requestUrl) &&
|
|
(!status || status === 0 || error.message?.includes('Network Error'))
|
|
) {
|
|
logger.info('[axios] Presigned URL failed, disabling presigned URLs', {
|
|
url: requestUrl.slice(0, 80),
|
|
});
|
|
disablePresignedUrls(); // Falls back to backend proxy
|
|
}
|
|
|
|
if (status === 401 && !isLoginRequest) {
|
|
// Clear stored tokens
|
|
sessionStorage.removeItem('token');
|
|
sessionStorage.removeItem('user');
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
delete axios.defaults.headers.common['Authorization'];
|
|
|
|
// Redirect to login if not already there
|
|
if (!window.location.pathname.includes('/login')) {
|
|
window.location.href = '/login';
|
|
}
|
|
}
|
|
}
|
|
return Promise.reject(error);
|
|
},
|
|
);
|
|
|
|
// Helper function to detect presigned S3 URLs
|
|
const isPresignedS3Url = (url: string): boolean => {
|
|
return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=');
|
|
};
|
|
```
|
|
|
|
**Presigned URL Fallback:** When S3 CORS is not configured, presigned URL requests fail with network errors. The interceptor detects these failures and automatically disables presigned URLs, falling back to the backend proxy for file downloads.
|
|
```
|
|
|
|
## Authentication Flows
|
|
|
|
### Local Login Flow
|
|
|
|
```
|
|
1. User enters email/password on login.tsx
|
|
2. dispatch(loginUser({ email, password }))
|
|
3. POST /auth/signin/local
|
|
4. Backend validates:
|
|
- User exists
|
|
- User not disabled
|
|
- Email verified (if email configured)
|
|
- Password matches (bcrypt.compare)
|
|
5. Return JWT token (6h expiration)
|
|
6. Frontend stores token in sessionStorage + localStorage
|
|
7. Set axios Authorization header
|
|
8. dispatch(findMe()) → GET /auth/me
|
|
9. Redirect to /dashboard
|
|
```
|
|
|
|
### Invitation-Only Account Flow
|
|
|
|
```
|
|
1. Administrator, Platform Owner, or Account Manager creates a user from Users.
|
|
2. Backend creates the user, assigns the selected role, and sends an invitation/setup email when email is configured.
|
|
3. User opens the setup link.
|
|
4. User sets a password through the password-reset/setup screen.
|
|
5. User can log in with email/password.
|
|
```
|
|
|
|
### Password Reset Flow
|
|
|
|
```
|
|
1. User enters email on forgot.tsx
|
|
2. POST /auth/send-password-reset-email
|
|
3. Backend generates passwordResetToken (40-char hex, 24h expiry)
|
|
4. Send email with link: /password-reset?token={token}
|
|
5. User clicks link → PasswordSetOrReset component
|
|
6. User enters new password
|
|
7. PUT /auth/password-reset with token + newPassword
|
|
8. Backend validates token, hashes new password, updates user
|
|
9. Clear token fields
|
|
10. Redirect to /login
|
|
```
|
|
|
|
### OAuth Flow (Google/Microsoft)
|
|
|
|
```
|
|
1. User clicks "Sign in with Google/Microsoft"
|
|
2. GET /auth/signin/google (or microsoft)
|
|
3. Passport initiates OAuth flow
|
|
4. User grants permissions
|
|
5. Provider redirects to callback
|
|
6. GET /auth/signin/google/callback?code=...
|
|
7. Backend exchanges code for access token
|
|
8. Retrieve user profile (email, name)
|
|
9. findOrCreate user by email
|
|
10. Auto-set emailVerified: true
|
|
11. Generate JWT token
|
|
12. Redirect to /login?token={jwt}
|
|
13. Frontend extracts token from URL, stores, redirects to /dashboard
|
|
```
|
|
|
|
### Protected Route Access
|
|
|
|
```
|
|
1. Frontend makes API request
|
|
2. Axios interceptor adds: Authorization: Bearer {token}
|
|
3. Backend: passport.authenticate('jwt') middleware
|
|
4. JWT strategy validates token signature and expiry
|
|
5. Decode payload: { user: { id, email } }
|
|
6. Query database for user
|
|
7. Check user.disabled - reject if true
|
|
8. Set req.currentUser = user
|
|
9. Proceed to route handler
|
|
```
|
|
|
|
## Security Features
|
|
|
|
### Password Security
|
|
|
|
| Feature | Implementation |
|
|
|---------|----------------|
|
|
| Algorithm | bcrypt |
|
|
| Salt Rounds | 12 |
|
|
| Storage | Hashed only, never plaintext |
|
|
| Comparison | `bcrypt.compare()` (constant-time) |
|
|
|
|
### Token Security
|
|
|
|
| Feature | Implementation |
|
|
|---------|----------------|
|
|
| JWT Algorithm | HS256 |
|
|
| Expiration | 6 hours |
|
|
| Secret | Environment variable `SECRET_KEY` |
|
|
| Transmission | Bearer token in Authorization header |
|
|
| Storage | sessionStorage + localStorage |
|
|
|
|
### Verification Tokens
|
|
|
|
| Token Type | Length | Format | Expiry |
|
|
|------------|--------|--------|--------|
|
|
| Email Verification | 40 chars | hex | 24 hours |
|
|
| Password Reset | 40 chars | hex | 24 hours |
|
|
|
|
### User Disabling
|
|
|
|
- **Field:** `disabled` boolean on users table
|
|
- **JWT Validation:** Strategy rejects disabled users
|
|
- **Auth Check:** Both signin and signup reject disabled users
|
|
- **Use Case:** Admin can disable accounts without deletion
|
|
|
|
## Protected Routes Pattern
|
|
|
|
**Backend middleware:**
|
|
```javascript
|
|
const jwtAuth = passport.authenticate('jwt', { session: false });
|
|
|
|
// Protected routes
|
|
app.use('/api/users', jwtAuth, usersRoutes);
|
|
app.use('/api/roles', jwtAuth, rolesRoutes);
|
|
app.use('/api/permissions', jwtAuth, permissionsRoutes);
|
|
```
|
|
|
|
**Runtime routes with optional auth (environment-based):**
|
|
|
|
The frontend sends `X-Runtime-Environment` header to indicate the environment context:
|
|
- `production` - Public tour pages (no auth required for GET requests)
|
|
- `stage` - Preview environment (requires authentication)
|
|
- `dev` - Constructor editing (requires authentication)
|
|
|
|
```javascript
|
|
const requireRuntimeReadOrAuth = (req, res, next) => {
|
|
const headerEnvironment = req.runtimeContext?.headerEnvironment;
|
|
const headerProjectSlug = req.runtimeContext?.headerProjectSlug;
|
|
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
|
|
|
|
// Only production is public. Stage requires authentication (workspace for review).
|
|
const isPublicEnvironment = headerEnvironment === 'production';
|
|
|
|
if (isPublicEnvironment && isReadOnlyRequest && !isPrivateProductionPresentation(headerProjectSlug)) {
|
|
req.isRuntimePublicRequest = true; // Allow public read access
|
|
return next();
|
|
}
|
|
// Private production presentations require JWT + staff permission
|
|
// or a production_presentation_access grant.
|
|
return jwtAuth(req, res, next);
|
|
};
|
|
```
|
|
|
|
Private production presentation visibility and customer grants are stored in the
|
|
database separately from broad RBAC permissions. See
|
|
[private-production-presentations.md](./private-production-presentations.md).
|
|
|
|
**Entities with public read access (production environment only):**
|
|
- `projects` - Project metadata (filtered fields)
|
|
- `tour_pages` - Page content including `ui_schema_json`
|
|
- `project_audio_tracks` - Background audio tracks
|
|
|
|
**File endpoints (no auth required):**
|
|
```javascript
|
|
// No authentication - used for runtime asset loading
|
|
GET /api/file/download?privateUrl={path} // Backend proxy for local/GCloud storage
|
|
POST /api/file/presign // Generate presigned URLs for S3 direct download
|
|
```
|
|
|
|
## Configuration
|
|
|
|
**File:** `backend/src/config.ts`
|
|
|
|
### Role Configuration
|
|
```javascript
|
|
roles: {
|
|
admin: 'Administrator',
|
|
user: 'Analytics Viewer',
|
|
}
|
|
```
|
|
|
|
Self-registration is disabled, so `config.roles.user` is not used by a public
|
|
signup endpoint.
|
|
|
|
### Password Configuration
|
|
```javascript
|
|
bcrypt: {
|
|
saltRounds: 12 // bcrypt hashing rounds
|
|
}
|
|
```
|
|
|
|
### Seed Credentials
|
|
|
|
Seeded user credentials are loaded from backend configuration and environment
|
|
values. The frontend login page must not display or prefill seeded credentials in
|
|
any environment.
|
|
|
|
## Environment Variables
|
|
|
|
**Required:**
|
|
```bash
|
|
# JWT
|
|
SECRET_KEY=your_secret_key_here
|
|
|
|
# Google OAuth
|
|
GOOGLE_CLIENT_ID=your_google_client_id
|
|
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
|
|
|
# Microsoft OAuth
|
|
MS_CLIENT_ID=your_microsoft_client_id
|
|
MS_CLIENT_SECRET=your_microsoft_client_secret
|
|
|
|
# Email (for verification/reset)
|
|
EMAIL_USER=your_smtp_username
|
|
EMAIL_PASS=your_smtp_password
|
|
```
|
|
|
|
**Frontend:**
|
|
```bash
|
|
NEXT_PUBLIC_BACK_API=http://localhost:3000/api
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
### Validation Errors (400)
|
|
```javascript
|
|
throw new ValidationError('auth.emailAlreadyInUse');
|
|
throw new ValidationError('auth.userNotFound');
|
|
throw new ValidationError('auth.wrongPassword');
|
|
throw new ValidationError('auth.userNotVerified');
|
|
```
|
|
|
|
### Forbidden Errors (403)
|
|
```javascript
|
|
throw new ForbiddenError(); // JWT invalid/missing
|
|
```
|
|
|
|
### Rate Limit Errors (429)
|
|
```javascript
|
|
res.status(429).send({
|
|
message: 'Too many requests. Please try again later.',
|
|
});
|
|
```
|
|
|
|
## File Reference
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `backend/src/auth/auth.ts` | Passport.js strategy configuration |
|
|
| `backend/src/routes/auth.ts` | Authentication route handlers |
|
|
| `backend/src/services/auth.ts` | Authentication business logic |
|
|
| `backend/src/middlewares/rateLimiter.ts` | Centralized rate limiting middleware |
|
|
| `backend/src/db/api/users.js` | User database operations |
|
|
| `backend/src/db/models/users.js` | User Sequelize model |
|
|
| `backend/src/config.ts` | Auth configuration (bcrypt, secrets) |
|
|
| `backend/src/helpers.ts` | JWT signing helper |
|
|
| `backend/src/middlewares/runtime-context.ts` | Runtime environment context (X-Runtime-Environment header) |
|
|
| `backend/src/middlewares/runtime-public.ts` | Public runtime access filtering and field sanitization |
|
|
| `frontend/src/stores/authSlice.ts` | Redux auth state |
|
|
| `frontend/src/pages/login.tsx` | Login page |
|
|
| `frontend/src/pages/forgot.tsx` | Forgot password page |
|
|
| `frontend/src/pages/verify-email.tsx` | Email verification page |
|
|
| `frontend/src/components/PasswordSetOrReset.tsx` | Password reset component |
|
|
| `frontend/src/pages/_app.tsx` | Axios interceptors, presigned URL failure detection |
|
|
| `frontend/src/lib/assetUrl.ts` | Presigned URL management and fallback logic |
|
|
|
|
## Troubleshooting
|
|
|
|
### Common Issues
|
|
|
|
**"auth.userNotVerified" error:**
|
|
- Email verification is required when email is configured
|
|
- Check user's `emailVerified` field in database
|
|
- Resend verification email via `/auth/send-email-address-verification-email`
|
|
|
|
**401 errors on all requests:**
|
|
- Token may be expired (6h limit)
|
|
- Token may be malformed
|
|
- Check Authorization header format: `Bearer {token}`
|
|
- Verify SECRET_KEY matches between token generation and validation
|
|
|
|
**Rate limit exceeded (429):**
|
|
- Wait for window to expire (15 min for signin, 1 hour for signup/reset)
|
|
- Rate limits are per IP address
|
|
- Consider adjusting limits in `backend/src/routes/auth.ts`
|
|
|
|
**OAuth callback fails:**
|
|
- Verify callback URLs match in provider console
|
|
- Check client ID and secret in environment variables
|
|
- Ensure OAuth scopes are properly configured
|
|
|
|
**Token not persisting after refresh:**
|
|
- Check both sessionStorage and localStorage
|
|
- Verify axios interceptors are properly attached
|
|
- Check for errors in browser console
|