File size: 7,158 Bytes
6e13570
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
const User = require("../models/User");
const AuditLog = require("../models/AuditLog");
const { signAccessToken, signRefreshToken, verifyRefreshToken } = require("../utils/jwt");
const logger = require("../utils/logger");

const MAX_FAILED_ATTEMPTS = 5;
const LOCK_DURATION_MS = 15 * 60 * 1000; // 15 minutes
const REFRESH_TOKEN_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;

// --------------------------------------------------------------------
// Refresh tokens are returned in the JSON response body (and stored by
// the client in localStorage) rather than as an httpOnly cookie.
//
// Why: this API and its frontend are deployed on different domains
// (e.g. a Hugging Face Space + a separate Vercel domain), and some
// hosting proxies in that kind of cross-site setup intercept CORS
// preflight requests before they reach the app and strip the
// Access-Control-Allow-Credentials header β€” which silently breaks any
// flow that depends on browsers sending a cross-site cookie. Avoiding
// `credentials: include` entirely sidesteps that class of problem.
//
// The trade-off: an httpOnly cookie can never be read by JavaScript,
// even from an XSS payload; a token sitting in localStorage can be.
// We narrow that risk with what's still in our control β€” access
// tokens stay short-lived (15 min) and refresh tokens rotate on every
// use with reuse detection (see `refresh` below) β€” but this is
// genuinely a lower security bar than the cookie-based approach, and
// worth knowing if you migrate this API to a same-site deployment
// later (at which point switching back to an httpOnly cookie is
// recommended).
// --------------------------------------------------------------------

async function audit(action, req, extra = {}) {
  try {
    await AuditLog.create({
      actor: req.user ? req.user._id : null,
      actorEmail: req.user ? req.user.email : extra.email || null,
      action,
      ip: req.ip,
      userAgent: req.headers["user-agent"],
      metadata: extra.metadata || {},
    });
  } catch (err) {
    logger.error("Failed to write audit log", { action, error: err.message });
  }
}

function issueSession(user, req) {
  const accessToken = signAccessToken(user);
  const { token: refreshToken, jti } = signRefreshToken(user);
  user.activeRefreshTokens = (user.activeRefreshTokens || [])
    .filter((rt) => rt.expiresAt > new Date()) // prune expired sessions as we go
    .concat({
      jti,
      expiresAt: new Date(Date.now() + REFRESH_TOKEN_LIFETIME_MS),
      userAgent: req.headers["user-agent"],
      ip: req.ip,
    });
  return { accessToken, refreshToken };
}

async function signup(req, res, next) {
  try {
    const { name, email, password } = req.body;

    const existing = await User.findOne({ email: email.toLowerCase() });
    if (existing) {
      // Do not reveal which part of the credential pair is wrong to
      // avoid account enumeration.
      return res.status(409).json({ error: "Unable to create account with the details provided." });
    }

    const user = new User({ name, email: email.toLowerCase() });
    await user.setPassword(password);
    await user.save();

    await audit("user.signup", req, { email: user.email });

    const { accessToken, refreshToken } = issueSession(user, req);
    await user.save();

    res.status(201).json({ user: user.toSafeJSON(), accessToken, refreshToken });
  } catch (err) {
    next(err);
  }
}

async function login(req, res, next) {
  try {
    const { email, password } = req.body;

    const user = await User.findOne({ email: email.toLowerCase() }).select(
      "+passwordHash +failedLoginAttempts +lockUntil +activeRefreshTokens"
    );

    // Constant-shaped response whether the user exists or not.
    const genericError = () => res.status(401).json({ error: "Invalid email or password." });

    if (!user || !user.isActive) return genericError();

    if (user.isLocked()) {
      return res.status(423).json({
        error: "Account temporarily locked due to repeated failed attempts. Try again later.",
      });
    }

    const valid = await user.comparePassword(password);
    if (!valid) {
      user.failedLoginAttempts = (user.failedLoginAttempts || 0) + 1;
      if (user.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) {
        user.lockUntil = new Date(Date.now() + LOCK_DURATION_MS);
        user.failedLoginAttempts = 0;
        await audit("user.login.locked", req, { email: user.email });
      }
      await user.save();
      return genericError();
    }

    user.failedLoginAttempts = 0;
    user.lockUntil = null;

    const { accessToken, refreshToken } = issueSession(user, req);
    await user.save();

    await audit("user.login", req, { email: user.email });

    res.json({ user: user.toSafeJSON(), accessToken, refreshToken });
  } catch (err) {
    next(err);
  }
}

async function refresh(req, res, next) {
  try {
    const { refreshToken: token } = req.body;
    if (!token) return res.status(401).json({ error: "No refresh token provided." });

    let payload;
    try {
      payload = verifyRefreshToken(token);
    } catch {
      return res.status(401).json({ error: "Invalid or expired session, please log in again." });
    }

    const user = await User.findById(payload.sub).select("+activeRefreshTokens");
    if (!user || !user.isActive || user.tokenVersion !== payload.tokenVersion) {
      return res.status(401).json({ error: "Session no longer valid, please log in again." });
    }

    const stillActive = (user.activeRefreshTokens || []).some((rt) => rt.jti === payload.jti);
    if (!stillActive) {
      // Possible reuse of an already-rotated (or logged-out) refresh
      // token β€” deny it. As a precaution, also revoke every other
      // active session for this account, since a replayed token is a
      // signal the original may have leaked.
      user.activeRefreshTokens = [];
      await user.save();
      return res.status(401).json({ error: "Session no longer valid, please log in again." });
    }

    // Rotate: remove old jti, issue a new refresh token.
    user.activeRefreshTokens = user.activeRefreshTokens.filter((rt) => rt.jti !== payload.jti);
    const { accessToken, refreshToken: newRefreshToken } = issueSession(user, req);
    await user.save();

    res.json({ accessToken, refreshToken: newRefreshToken, user: user.toSafeJSON() });
  } catch (err) {
    next(err);
  }
}

async function logout(req, res, next) {
  try {
    const { refreshToken: token } = req.body;
    if (token) {
      try {
        const payload = verifyRefreshToken(token);
        await User.findByIdAndUpdate(payload.sub, { $pull: { activeRefreshTokens: { jti: payload.jti } } });
      } catch {
        // token already invalid β€” nothing to revoke
      }
    }
    res.json({ success: true });
  } catch (err) {
    next(err);
  }
}

async function me(req, res) {
  res.json({ user: req.user.toSafeJSON() });
}

module.exports = { signup, login, refresh, logout, me };