Complete user authentication for the Servio platform.
| Layer | Tech | Details |
|---|---|---|
| Backend | Node.js + Express | REST API, JWT auth, email OTP |
| Database | PostgreSQL + Prisma | 3 models: User, EmailVerification, RefreshToken |
| Frontend | Next.js 14 (App Router) | Register, Login, Verify Email, Dashboard |
| Nodemailer | Ethereal auto-account in dev, SMTP in prod | |
| Security | bcrypt, JWT rotation, httpOnly cookies, Helmet, rate limiting |
servio/
├── backend/
│ ├── prisma/
│ │ └── schema.prisma # DB schema (3 models)
│ ├── src/
│ │ ├── config/
│ │ │ ├── database.js # Prisma client
│ │ │ └── env.js # Zod-validated env vars
│ │ ├── middleware/
│ │ │ ├── authenticate.js # JWT middleware + authorize()
│ │ │ └── errorHandler.js # Centralized error handler
│ │ ├── modules/auth/
│ │ │ ├── auth.service.js # All business logic
│ │ │ ├── auth.controller.js # HTTP layer + cookie management
│ │ │ ├── auth.routes.js # Route definitions
│ │ │ └── auth.validators.js # Zod schemas + validate() factory
│ │ └── utils/
│ │ ├── asyncHandler.js # Wraps async routes
│ │ ├── email.js # Nodemailer + HTML templates
│ │ └── jwt.js # Token generation + hashing
│ ├── .env.example
│ └── package.json
│
└── frontend/
└── src/
├── app/
│ ├── (auth)/
│ │ ├── layout.jsx # Split panel auth layout
│ │ ├── register/ # Registration form
│ │ ├── login/ # Login form
│ │ └── verify-email/ # OTP input (6-digit)
│ ├── dashboard/ # Protected page
│ └── layout.jsx # Root layout + AuthProvider
├── components/auth/
│ ├── FormInput.jsx # Input w/ label, error, password toggle
│ └── PasswordStrength.jsx # Live password strength checker
├── contexts/
│ └── AuthContext.jsx # Global auth state + actions
└── lib/
└── api.js # Axios client + silent token refresh
- Node.js 18+
- PostgreSQL running locally
- Git
cd servio/backend
# Copy and fill in env vars
cp .env.example .envEdit .env and set at minimum:
DATABASE_URL="postgresql://postgres:yourpassword@localhost:5432/servio_db"
ACCESS_TOKEN_SECRET=generate_a_64_char_random_string_here
REFRESH_TOKEN_SECRET=generate_another_64_char_random_string_hereGenerate secrets quickly:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"# Install dependencies
npm install
# Create the database tables
npx prisma migrate dev --name init
# (Optional) Open Prisma Studio to inspect DB
npx prisma studio
# Start the API server
npm run devAPI will run on http://localhost:5000
In development, when the first email is sent, Nodemailer will print an Ethereal preview URL to the terminal — click it to see the OTP email without needing real SMTP.
cd servio/frontend
# Install dependencies
npm install
# Start Next.js dev server
npm run devFrontend will run on http://localhost:3000
All endpoints prefixed: /api/v1/auth
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /register |
— | Register new user |
| POST | /verify-email |
— | Verify email with OTP |
| POST | /resend-verification |
— | Resend OTP |
| POST | /login |
— | Login → access token + cookie |
| POST | /logout |
— | Revoke refresh token + clear cookie |
| POST | /refresh-token |
cookie | Rotate tokens |
| GET | /me |
Bearer | Get current user |
Register
POST /api/v1/auth/register
{
"fullName": "Alex Johnson",
"email": "alex@example.com",
"password": "SecurePass1",
"confirmPassword": "SecurePass1"
}
→ 201
{
"success": true,
"data": {
"email": "alex@example.com",
"message": "Registration successful. Please check your email."
}
}Verify email
POST /api/v1/auth/verify-email
{ "email": "alex@example.com", "code": "482910" }
→ 200
{
"success": true,
"data": {
"user": { "id": "...", "email": "...", "fullName": "...", "role": "USER", "isVerified": true },
"accessToken": "eyJ..."
}
}
// + Set-Cookie: servio_refresh=...; HttpOnly; SameSite=LaxLogin
POST /api/v1/auth/login
{ "email": "alex@example.com", "password": "SecurePass1" }
→ 200 (same shape as verify-email response)Error shape
→ 400/401/409
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"fields": {
"email": "Please enter a valid email address",
"password": "Password must contain at least one number"
}
}
}| Concern | Implementation |
|---|---|
| Password storage | bcrypt, cost factor 12 |
| Access token | JWT, 15-min expiry, in-memory on client |
| Refresh token | Random 64-byte hex, SHA-256 hashed in DB, 7-day expiry |
| Refresh token rotation | Old token revoked on every refresh; replay detection revokes all user tokens |
| HTTP-only cookie | Refresh token never accessible via JS (XSS protection) |
| Timing attack prevention | Password check always runs even if user not found |
| Rate limiting | Auth routes: 20 req/15min; General: 100 req/min |
| Input validation | Zod schemas on every endpoint before controller runs |
| Error information leakage | Generic 404/401 messages that don't reveal email existence |
Register → Email sent with 6-digit OTP → Enter OTP on /verify-email
→ Logged in automatically → /dashboard
Login → /dashboard (or verify-email page if unverified)
On page refresh → silent refresh via HTTP-only cookie → session restored
Logout → refresh token revoked server-side → cookie cleared
const authenticate = require('./middleware/authenticate');
const { authorize } = require('./middleware/authenticate');
// Require any logged-in user
router.get('/profile', authenticate, controller.getProfile);
// Require specific role
router.get('/admin/users', authenticate, authorize('ADMIN'), controller.listUsers);The schema already has role: ENUM with PROFESSIONAL and ADMIN values.
Next step is adding the professional_profiles table and a separate onboarding flow
that routes to the admin approval queue.