Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
DB_HOST=
DB_PORT=
DB_NAME=
DB_USER=
DB_PASS=
PORT=5000
CORS_ORIGIN=http://localhost:3000
APP_URL=http://localhost:3000
NODE_ENV=development
TRUST_PROXY=
API_KEY=replace-with-random-api-key

PORT=
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME=auth_api
DB_USER=postgres
DB_PASS=postgres
DB_POOL_MAX=10

API_KEY=
JWT_ACCESS_SECRET=replace-with-a-unique-random-access-secret-at-least-32-characters
JWT_REFRESH_SECRET=replace-with-a-different-random-refresh-secret-at-least-32-characters
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL=30d
REQUIRE_EMAIL_VERIFICATION=true

# SMTP is required in production for verification and password reset emails.
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
MAIL_FROM=Auth API <no-reply@example.com>
196 changes: 125 additions & 71 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,97 +1,151 @@
# ⚠ In progress ⚠


# 🚧 API Routes 🚧

## `[GET] /`

### Response

- ✅ Status: **200**

```javascript
message: "It works! ^^",
# Auth-API

Einbettbares Authentifizierungsmodul für Express-Anwendungen mit PostgreSQL, Argon2id sowie kurzlebigen JWT Access Tokens und rotierenden Refresh Tokens. Das Paket startet beim Import keinen Server und kann ähnlich einem Auth-Provider als Router und Middleware in eine vorhandene Anwendung eingebunden werden. Der mitgelieferte Standalone-Server ist lediglich ein optionales Beispiel.

## Als Modul verwenden

```js
const express = require('express');
const { Pool } = require('pg');
const { createAuthModule } = require('auth-api');

const app = express();
app.use(express.json());

const auth = createAuthModule({
database: new Pool({ connectionString: process.env.DATABASE_URL }),
apiKey: process.env.AUTH_API_KEY,
jwtAccessSecret: process.env.JWT_ACCESS_SECRET,
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET,
appUrl: 'https://app.example.com',
smtp: {
host: process.env.SMTP_HOST,
port: 587,
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
authPath: '/v1/auth',
adminPath: '/v1/admin',
exposeAdminApi: true,
});

await auth.initialize();
app.use(auth.router);

app.get('/private', auth.middleware.authenticate, (req, res) => {
res.json({ userId: req.user.sub });
});
```

## `[POST] /login`

### Request

```javascript
{
username: string;
password: string;
apiKey: string;
}
`createAuthModule()` liefert:

- `router`: frei mountbarer Express-Router mit allen Auth-Endpunkten
- `initialize()`: idempotente Initialisierung des Schemas
- `middleware.authenticate`: Bearer-Token-Prüfung für eigene Routen
- `middleware.authorize(...roles)`: rollenbasierter Schutz für eigene Routen
- `middleware.validateApiKey`: API-Key-Schutz für eigene Routen
- `close()`: beendet den intern erzeugten Pool; ein injizierter Pool bleibt Eigentum der Host-Anwendung

Mit `exposeAdminApi: false` kann die mitgelieferte Admin-HTTP-API vollständig deaktiviert werden. `authPath` und `adminPath` sind frei konfigurierbar. Alternativ können die Middleware über `require('auth-api/middleware')` importiert werden.

## Sicherheitsfunktionen

- Registrierung mit normalisierten E-Mail-Adressen und strenger Eingabevalidierung
- Argon2id-Passwort-Hashing und Passwortregeln (12–128 Zeichen, Groß-/Kleinbuchstaben, Zahl, Sonderzeichen)
- E-Mail-Verifizierung mit einmal verwendbaren, nur als SHA-256-Hash gespeicherten Tokens
- Login per Benutzername oder E-Mail; generische Antwort bei ungültigen Zugangsdaten
- Getrennte JWT-Secrets, Audience/Issuer-Prüfung und Refresh-Token-Rotation
- Erkennung wiederverwendeter Refresh Tokens mit Sperrung aller Sessions
- Passwort-vergessen/reset-Flow; Reset sperrt alle bestehenden Sessions
- Session-Verwaltung (anzeigen, einzeln oder vollständig widerrufen)
- Profil bearbeiten, Passwort ändern und eigenes Konto löschen
- Rollenbasierte Admin-Autorisierung mit aktueller Datenbankprüfung (`user`/`admin`)
- API-Key, Helmet, CORS-Allowlist und Rate Limits auf Auth-Endpunkten
- Parameterisierte SQL-Abfragen, eindeutige Datenbank-Constraints und keine Klartext-Speicherung sensibler Tokens

## Installation

```bash
npm install
cp .env.example .env
npm start
```

### Response

- ✅ Status: **200**
PostgreSQL muss erreichbar sein. Beim Start werden die benötigten Tabellen und Indizes idempotent angelegt. Der Start bricht bei fehlenden Variablen, Secrets unter 32 Zeichen, identischen JWT-Secrets oder fehlendem SMTP in Produktion bewusst ab. Setze in Produktion lange, unabhängige Werte für `API_KEY`, `JWT_ACCESS_SECRET` und `JWT_REFRESH_SECRET`, `NODE_ENV=production`, eine explizite `CORS_ORIGIN`-Allowlist und die SMTP-Variablen. Mehrere CORS-Origins werden kommasepariert angegeben.

```javascript
message: "Logged In",
```
In der Entwicklung werden E-Mail-Links in der Konsole protokolliert und das jeweilige Einmal-Token zusätzlich in der API-Antwort geliefert. In Produktion werden Tokens niemals ausgeliefert; ein SMTP-Server ist dort erforderlich.

- ❌ Status: **401**
## Authentifizierungsablauf

```javascript
message: "Invalid data",
```
Alle `/auth`- und `/admin`-Aufrufe brauchen `x-api-key: <API_KEY>`. Geschützte Benutzer-Endpunkte brauchen zusätzlich `Authorization: Bearer <accessToken>`.

- ❌ Status: **404**
1. `POST /auth/register`
2. Token aus der E-Mail an `POST /auth/verify-email` senden
3. `POST /auth/login` und Access-/Refresh-Token sicher speichern
4. Access Token für geschützte Aufrufe nutzen
5. Nach Ablauf mit `POST /auth/refresh` beide Tokens ersetzen (das alte Refresh Token sofort verwerfen)
6. Beim Abmelden `POST /auth/logout` oder `POST /auth/logout-all` aufrufen

```javascript
message: "User not found",
```
## Endpunkte

## `[POST] /register`
### Öffentlich

### Request
| Methode | Pfad | Beschreibung |
|---|---|---|
| `GET` | `/` | API-Status |
| `GET` | `/health` | Healthcheck |

```javascript
{
username: string;
password: string;
key: string;
apiKey: string;
}
```
### Auth

### Response
| Methode | Pfad | Bearer | Body / Beschreibung |
|---|---|---:|---|
| `POST` | `/auth/register` | Nein | `{ "username", "email", "password" }` |
| `POST` | `/auth/verify-email` | Nein | `{ "token" }` |
| `POST` | `/auth/resend-verification` | Nein | `{ "email" }` |
| `POST` | `/auth/login` | Nein | `{ "identifier", "password" }` |
| `POST` | `/auth/refresh` | Nein | `{ "refreshToken" }` |
| `POST` | `/auth/forgot-password` | Nein | `{ "email" }` |
| `POST` | `/auth/reset-password` | Nein | `{ "token", "newPassword" }` |
| `POST` | `/auth/logout` | Nein | `{ "refreshToken" }` |
| `POST` | `/auth/logout-all` | Ja | Sperrt alle Refresh Sessions |
| `GET` | `/auth/me` | Ja | Eigenes Profil |
| `PATCH` | `/auth/me` | Ja | `{ "username"?, "email"? }`; neue E-Mail muss erneut verifiziert werden |
| `DELETE` | `/auth/me` | Ja | `{ "password" }` |
| `POST` | `/auth/change-password` | Ja | `{ "currentPassword", "newPassword" }` |
| `GET` | `/auth/sessions` | Ja | Aktive Refresh Sessions |
| `DELETE` | `/auth/sessions/:id` | Ja | Eigene Session widerrufen |

- ✅ Status: **201**
### Admin

```javascript
message: "User created",
```
Admin-Routen prüfen die aktuelle Rolle bei jedem Aufruf in der Datenbank. Ein Access Token allein kann eine entzogene Adminrolle daher nicht behalten.

- ❌ Status: **409**
| Methode | Pfad | Beschreibung |
|---|---|---|
| `GET` | `/admin/users` | Benutzer auflisten |
| `GET` | `/admin/users/:id` | Benutzer laden |
| `PATCH` | `/admin/users/:id` | `username`, `email`, `isActive` und/oder `role` ändern |
| `DELETE` | `/admin/users/:id` | Benutzer und seine Tokens löschen |

```javascript
message: "Key already used",
```

- ❌ Status: **404**
Der erste Admin wird bewusst nicht über eine öffentliche Route erstellt. Weise die Rolle einmalig direkt in PostgreSQL zu:

```javascript
message: "Key not found",
```sql
UPDATE users SET role = 'admin' WHERE email = 'admin@example.com';
```

- ❌ Status: **405**
## Beispiel

```javascript
message: "User already exists",
```bash
curl -X POST http://localhost:5000/auth/login \
-H 'content-type: application/json' \
-H 'x-api-key: replace-with-random-api-key' \
-d '{"identifier":"demo@example.com","password":"StrongPassword123!"}'
```

- ❌ Status: **401**
## Qualität

```javascript
message: "Invalid API Key",
```bash
npm test
npm run check
```

- ❌ Status: **400**

```javascript
message: "Missing fields",
```
Für horizontale Skalierung sollte das In-Memory-Rate-Limit durch einen gemeinsamen Store (zum Beispiel Redis) ersetzt werden. HTTPS, Secret Rotation, zentralisiertes Audit Logging und regelmäßige Dependency-/Datenbank-Backups bleiben Aufgaben der Deployment-Umgebung.
47 changes: 47 additions & 0 deletions auth-module.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
const express = require('express');
const { configure, getConfig, resetConfig } = require('./utils/config');
const database = require('./utils/database');
const initDatabase = require('./utils/initDatabase');
const validateConfig = require('./utils/validateConfig');
const authenticate = require('./middleware/authenticate');
const authorize = require('./middleware/authorize');
const validateApiKey = require('./middleware/validateApiKey');
const authRouter = require('./routes/auth/router');
const adminRouter = require('./routes/admin');

/**
* Creates an embeddable authentication module for an existing Express app.
* The returned router owns no HTTP server and can be mounted at any path.
*/
const createAuthModule = (options = {}) => {
const {
database: externalDatabase,
authPath = '/auth',
adminPath = '/admin',
exposeAdminApi = true,
...authConfig
} = options;

configure(authConfig);
if (externalDatabase) database.usePool(externalDatabase);

const router = express.Router();
router.use(authPath, authRouter);
if (exposeAdminApi) router.use(adminPath, adminRouter);

return Object.freeze({
router,
config: getConfig(),
initialize: async () => {
validateConfig({ requireDatabaseEnv: !externalDatabase });
await initDatabase();
},
middleware: Object.freeze({ authenticate, authorize, validateApiKey }),
close: async () => {
await database.close();
resetConfig();
},
});
};

module.exports = { createAuthModule };
43 changes: 33 additions & 10 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,53 @@
let $console = require('Console');
require('dotenv').config();

const express = require('express');
const cors = require('cors');
const logger = require('morgan');
const helmet = require('helmet');

const { createAuthModule } = require('./auth-module');
const mainRoute = require('./routes/main');

const server = express();
const port = process.env.PORT || 5000;
const auth = createAuthModule();

const router = require('./routes/router')(server);
const db = require('./utils/database');
if (process.env.TRUST_PROXY) server.set('trust proxy', Number(process.env.TRUST_PROXY));

global.db = db;
const allowedOrigins = (process.env.CORS_ORIGIN || '').split(',').map((origin) => origin.trim()).filter(Boolean);

server.use(express.json());
server.disable('x-powered-by');
server.use(express.json({ limit: '32kb' }));
server.use(cors({
origin: process.env.CORS_ORIGIN,
credentials: true,
origin: allowedOrigins.length ? allowedOrigins : false,
credentials: allowedOrigins.length > 0,
}));
server.use(logger('dev'));
server.use(helmet());
server.use(express.urlencoded({ extended: false }));

server.listen(port, () => {
$console.success(`[√] Server is listening on port ${port}`);
server.use('/', mainRoute);
server.use('/', auth.router);

server.use((req, res) => res.status(404).json({ message: 'Route not found.' }));
server.use((err, req, res, next) => {
console.error(err);
if (res.headersSent) return next(err);
return res.status(500).json({ message: 'Internal server error.' });
});

module.exports = server;
const start = async () => {
try {
await auth.initialize();
server.listen(port, () => {
console.info(`[√] Server is listening on port ${port}`);
});
} catch (err) {
console.error(`Failed to start server: ${err.message}`);
process.exit(1);
}
};

if (require.main === module) start();

module.exports = server;
21 changes: 21 additions & 0 deletions middleware/authenticate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const { verifyAccessToken } = require('../utils/auth/tokens');

const authenticate = (req, res, next) => {
const authHeader = req.header('authorization');

if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Missing bearer token.' });
}

const token = authHeader.split(' ')[1];

try {
const decoded = verifyAccessToken(token);
req.user = decoded;
return next();
} catch (err) {
return res.status(401).json({ message: 'Invalid or expired token.' });
}
};

module.exports = authenticate;
Loading