164 lines
4.3 KiB
JavaScript
164 lines
4.3 KiB
JavaScript
const express = require('express');
|
|
const cors = require('cors');
|
|
const morgan = require('morgan');
|
|
const { Pool } = require('pg');
|
|
const { createClient } = require('redis');
|
|
const dotenv = require('dotenv');
|
|
|
|
dotenv.config();
|
|
|
|
const app = express();
|
|
const port = process.env.PORT || 3000;
|
|
|
|
// Middleware
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
app.use(morgan('combined'));
|
|
|
|
// Database
|
|
const pool = new Pool({
|
|
connectionString: process.env.DATABASE_URL
|
|
});
|
|
|
|
// Redis
|
|
const redisClient = createClient({
|
|
url: process.env.REDIS_URL
|
|
});
|
|
redisClient.on('error', (err) => console.log('Redis Client Error', err));
|
|
|
|
(async () => {
|
|
await redisClient.connect();
|
|
console.log('Connected to Redis');
|
|
})();
|
|
|
|
// Health Check
|
|
app.get('/health', (req, res) => {
|
|
res.json({ status: 'ok', timestamp: new Date() });
|
|
});
|
|
|
|
const srp = require('secure-remote-password/server');
|
|
const jwt = require('jsonwebtoken');
|
|
|
|
// Route: Registration
|
|
app.post('/auth/register', async (req, res) => {
|
|
try {
|
|
const { email, salt, verifier } = req.body;
|
|
if (!email || !salt || !verifier) {
|
|
return res.status(400).json({ error: 'Missing fields' });
|
|
}
|
|
|
|
// Check if user exists
|
|
const userCheck = await pool.query('SELECT id FROM api.users WHERE email = $1', [email]);
|
|
if (userCheck.rows.length > 0) {
|
|
return res.status(409).json({ error: 'User already exists' });
|
|
}
|
|
|
|
await pool.query(
|
|
'INSERT INTO api.users (email, salt, verifier) VALUES ($1, $2, $3)',
|
|
[email, salt, verifier]
|
|
);
|
|
res.status(201).json({ message: 'User registered' });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Registration failed' });
|
|
}
|
|
});
|
|
|
|
// Route: Login Step 1
|
|
app.post('/auth/login/step1', async (req, res) => {
|
|
try {
|
|
const { email, clientPublic } = req.body; // clientPublic is 'A'
|
|
if (!email || !clientPublic) return res.status(400).json({ error: 'Missing email or clientPublic' });
|
|
|
|
const result = await pool.query('SELECT id, salt, verifier FROM api.users WHERE email = $1', [email]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'User not found' });
|
|
}
|
|
const user = result.rows[0];
|
|
|
|
// Generate server ephemeral (B)
|
|
const serverEphemeral = srp.generateEphemeral(user.verifier);
|
|
// serverEphemeral contains { secret, public } (b, B)
|
|
|
|
// Store session in Redis
|
|
const sessionKey = `srp:${email}`;
|
|
await redisClient.set(sessionKey, JSON.stringify({
|
|
salt: user.salt,
|
|
verifier: user.verifier,
|
|
serverSecret: serverEphemeral.secret,
|
|
serverPublic: serverEphemeral.public,
|
|
clientPublic: clientPublic,
|
|
userId: user.id
|
|
}), {
|
|
EX: 300 // 5 minutes expiration
|
|
});
|
|
|
|
res.json({
|
|
salt: user.salt,
|
|
serverPublic: serverEphemeral.public
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Login step 1 failed' });
|
|
}
|
|
});
|
|
|
|
// Route: Login Step 2
|
|
app.post('/auth/login/step2', async (req, res) => {
|
|
try {
|
|
const { email, clientProof } = req.body; // clientProof is 'M1'
|
|
if (!email || !clientProof) return res.status(400).json({ error: 'Missing email or clientProof' });
|
|
|
|
const sessionKey = `srp:${email}`;
|
|
const sessionDataString = await redisClient.get(sessionKey);
|
|
|
|
if (!sessionDataString) {
|
|
return res.status(401).json({ error: 'Session expired or invalid' });
|
|
}
|
|
|
|
const session = JSON.parse(sessionDataString);
|
|
|
|
// Calculate server session
|
|
const serverSession = srp.deriveSession(
|
|
session.serverSecret,
|
|
session.clientPublic,
|
|
session.salt,
|
|
email,
|
|
session.verifier,
|
|
clientProof
|
|
);
|
|
|
|
if (!serverSession) {
|
|
return res.status(401).json({ error: 'Authentication failed' });
|
|
}
|
|
|
|
// Generate JWT
|
|
const token = jwt.sign(
|
|
{
|
|
sub: session.userId,
|
|
role: 'authenticated', // For PostgREST
|
|
email: email
|
|
},
|
|
process.env.JWT_SECRET,
|
|
{ expiresIn: '1h' }
|
|
);
|
|
|
|
// Clear session (optional, prevents replay of step 2 within window, though M1 is unique per session usually)
|
|
await redisClient.del(sessionKey);
|
|
|
|
res.json({
|
|
serverProof: serverSession.proof, // M2
|
|
token: token
|
|
});
|
|
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Login step 2 failed' });
|
|
}
|
|
});
|
|
|
|
|
|
app.listen(port, () => {
|
|
console.log(`API listening on port ${port}`);
|
|
});
|