first commit
This commit is contained in:
497
routes/auth.js
Normal file
497
routes/auth.js
Normal file
@@ -0,0 +1,497 @@
|
||||
import { generateTokenPair } from "../utils/jwt.js";
|
||||
import {
|
||||
authenticate,
|
||||
verifyRefreshTokenMiddleware,
|
||||
} from "../middleware/auth.js";
|
||||
import { config } from "../config/index.js";
|
||||
import Session from "../models/Session.js";
|
||||
|
||||
/**
|
||||
* Authentication routes
|
||||
* @param {FastifyInstance} fastify - Fastify instance
|
||||
*/
|
||||
export default async function authRoutes(fastify, options) {
|
||||
// Debug endpoint to check cookies and headers (development only)
|
||||
fastify.get("/debug-cookies", async (request, reply) => {
|
||||
if (config.isProduction) {
|
||||
return reply.status(404).send({ error: "Not found" });
|
||||
}
|
||||
|
||||
// Parse the raw cookie header manually to compare
|
||||
const rawCookieHeader = request.headers.cookie || "";
|
||||
const manualParsedCookies = {};
|
||||
if (rawCookieHeader) {
|
||||
rawCookieHeader.split(";").forEach((cookie) => {
|
||||
const [name, ...valueParts] = cookie.trim().split("=");
|
||||
if (name) {
|
||||
manualParsedCookies[name] = valueParts.join("=");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
cookies: request.cookies || {},
|
||||
manualParsedCookies: manualParsedCookies,
|
||||
rawCookieHeader: rawCookieHeader,
|
||||
headers: {
|
||||
authorization: request.headers.authorization || null,
|
||||
origin: request.headers.origin || null,
|
||||
referer: request.headers.referer || null,
|
||||
cookie: request.headers.cookie ? "Present" : "Missing",
|
||||
host: request.headers.host || null,
|
||||
},
|
||||
hasAccessToken: !!request.cookies?.accessToken,
|
||||
hasRefreshToken: !!request.cookies?.refreshToken,
|
||||
manualHasAccessToken: !!manualParsedCookies.accessToken,
|
||||
manualHasRefreshToken: !!manualParsedCookies.refreshToken,
|
||||
config: {
|
||||
cookieDomain: config.cookie.domain,
|
||||
cookieSecure: config.cookie.secure,
|
||||
cookieSameSite: config.cookie.sameSite,
|
||||
corsOrigin: config.cors.origin,
|
||||
},
|
||||
note: "If request.cookies is empty but manualParsedCookies has data, then @fastify/cookie isn't parsing correctly",
|
||||
});
|
||||
});
|
||||
|
||||
// Test endpoint to verify Steam configuration
|
||||
fastify.get("/steam/test", async (request, reply) => {
|
||||
return reply.send({
|
||||
success: true,
|
||||
steamConfig: {
|
||||
apiKeySet: !!config.steam.apiKey,
|
||||
realm: config.steam.realm,
|
||||
returnURL: config.steam.returnURL,
|
||||
},
|
||||
message: "Steam authentication is configured. Try /auth/steam to login.",
|
||||
});
|
||||
});
|
||||
|
||||
// Steam login - initiate OAuth flow
|
||||
fastify.get("/steam", async (request, reply) => {
|
||||
try {
|
||||
// Manually construct the Steam OpenID URL
|
||||
const returnURL = encodeURIComponent(config.steam.returnURL);
|
||||
const realm = encodeURIComponent(config.steam.realm);
|
||||
|
||||
const steamOpenIDURL =
|
||||
`https://steamcommunity.com/openid/login?` +
|
||||
`openid.mode=checkid_setup&` +
|
||||
`openid.ns=http://specs.openid.net/auth/2.0&` +
|
||||
`openid.identity=http://specs.openid.net/auth/2.0/identifier_select&` +
|
||||
`openid.claimed_id=http://specs.openid.net/auth/2.0/identifier_select&` +
|
||||
`openid.return_to=${returnURL}&` +
|
||||
`openid.realm=${realm}`;
|
||||
|
||||
return reply.redirect(steamOpenIDURL);
|
||||
} catch (error) {
|
||||
console.error("❌ Steam authentication error:", error);
|
||||
return reply.status(500).send({
|
||||
error: "AuthenticationError",
|
||||
message: "Failed to initiate Steam login",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Steam OAuth callback - Manual verification
|
||||
fastify.get("/steam/return", async (request, reply) => {
|
||||
try {
|
||||
const query = request.query;
|
||||
|
||||
// Verify this is a valid OpenID response
|
||||
if (query["openid.mode"] !== "id_res") {
|
||||
return reply.status(400).send({
|
||||
error: "AuthenticationError",
|
||||
message: "Invalid OpenID response mode",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the response with Steam
|
||||
const verifyParams = new URLSearchParams();
|
||||
for (const key in query) {
|
||||
verifyParams.append(key, query[key]);
|
||||
}
|
||||
verifyParams.set("openid.mode", "check_authentication");
|
||||
|
||||
const verifyResponse = await fetch(
|
||||
"https://steamcommunity.com/openid/login",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: verifyParams.toString(),
|
||||
}
|
||||
);
|
||||
|
||||
const verifyText = await verifyResponse.text();
|
||||
|
||||
if (!verifyText.includes("is_valid:true")) {
|
||||
console.error("❌ Steam OpenID verification failed");
|
||||
return reply.status(401).send({
|
||||
error: "AuthenticationError",
|
||||
message: "Steam authentication verification failed",
|
||||
});
|
||||
}
|
||||
|
||||
// Extract Steam ID from the claimed_id
|
||||
const claimedId = query["openid.claimed_id"];
|
||||
const steamIdMatch = claimedId.match(/(\d+)$/);
|
||||
|
||||
if (!steamIdMatch) {
|
||||
return reply.status(400).send({
|
||||
error: "AuthenticationError",
|
||||
message: "Could not extract Steam ID",
|
||||
});
|
||||
}
|
||||
|
||||
const steamId = steamIdMatch[1];
|
||||
|
||||
// Get user profile from Steam API
|
||||
const steamApiUrl = `http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${config.steam.apiKey}&steamids=${steamId}`;
|
||||
const profileResponse = await fetch(steamApiUrl);
|
||||
const profileData = await profileResponse.json();
|
||||
|
||||
if (
|
||||
!profileData.response ||
|
||||
!profileData.response.players ||
|
||||
profileData.response.players.length === 0
|
||||
) {
|
||||
return reply.status(400).send({
|
||||
error: "AuthenticationError",
|
||||
message: "Could not fetch Steam profile",
|
||||
});
|
||||
}
|
||||
|
||||
const profile = profileData.response.players[0];
|
||||
|
||||
// Import User model
|
||||
const User = (await import("../models/User.js")).default;
|
||||
|
||||
// Find or create user
|
||||
let user = await User.findOne({ steamId });
|
||||
|
||||
if (user) {
|
||||
// Update existing user
|
||||
user.username = profile.personaname;
|
||||
user.avatar =
|
||||
profile.avatarfull || profile.avatarmedium || profile.avatar;
|
||||
user.communityvisibilitystate = profile.communityvisibilitystate;
|
||||
await user.save();
|
||||
console.log(
|
||||
`✅ Existing user logged in: ${user.username} (${steamId})`
|
||||
);
|
||||
} else {
|
||||
// Create new user
|
||||
user = new User({
|
||||
username: profile.personaname,
|
||||
steamId: steamId,
|
||||
avatar: profile.avatarfull || profile.avatarmedium || profile.avatar,
|
||||
account_creation:
|
||||
profile.timecreated || Math.floor(Date.now() / 1000),
|
||||
communityvisibilitystate: profile.communityvisibilitystate,
|
||||
balance: 0,
|
||||
staffLevel: 0,
|
||||
});
|
||||
await user.save();
|
||||
console.log(`✅ New user registered: ${user.username} (${steamId})`);
|
||||
}
|
||||
|
||||
// Generate JWT tokens
|
||||
const { accessToken, refreshToken } = generateTokenPair(user);
|
||||
|
||||
// Extract device information
|
||||
const userAgent = request.headers["user-agent"] || "Unknown";
|
||||
const ip =
|
||||
request.ip ||
|
||||
request.headers["x-forwarded-for"] ||
|
||||
request.headers["x-real-ip"] ||
|
||||
"Unknown";
|
||||
|
||||
// Simple device detection
|
||||
let device = "Desktop";
|
||||
let browser = "Unknown";
|
||||
let os = "Unknown";
|
||||
|
||||
if (
|
||||
userAgent.includes("Mobile") ||
|
||||
userAgent.includes("Android") ||
|
||||
userAgent.includes("iPhone")
|
||||
) {
|
||||
device = "Mobile";
|
||||
} else if (userAgent.includes("Tablet") || userAgent.includes("iPad")) {
|
||||
device = "Tablet";
|
||||
}
|
||||
|
||||
if (userAgent.includes("Chrome")) browser = "Chrome";
|
||||
else if (userAgent.includes("Firefox")) browser = "Firefox";
|
||||
else if (userAgent.includes("Safari")) browser = "Safari";
|
||||
else if (userAgent.includes("Edge")) browser = "Edge";
|
||||
|
||||
if (userAgent.includes("Windows")) os = "Windows";
|
||||
else if (userAgent.includes("Mac")) os = "macOS";
|
||||
else if (userAgent.includes("Linux")) os = "Linux";
|
||||
else if (userAgent.includes("Android")) os = "Android";
|
||||
else if (userAgent.includes("iOS") || userAgent.includes("iPhone"))
|
||||
os = "iOS";
|
||||
|
||||
// Create session
|
||||
try {
|
||||
const session = new Session({
|
||||
userId: user._id,
|
||||
steamId: user.steamId,
|
||||
token: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
ip: ip,
|
||||
userAgent: userAgent,
|
||||
device: device,
|
||||
browser: browser,
|
||||
os: os,
|
||||
location: {
|
||||
country: null, // Can integrate with IP geolocation service
|
||||
city: null,
|
||||
region: null,
|
||||
},
|
||||
isActive: true,
|
||||
lastActivity: new Date(),
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
||||
});
|
||||
|
||||
await session.save();
|
||||
console.log(
|
||||
`📱 Session created for ${user.username} from ${ip} (${device}/${browser})`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to create session:", error);
|
||||
// Don't fail login if session creation fails
|
||||
}
|
||||
|
||||
// Set cookies
|
||||
reply
|
||||
.setCookie("accessToken", accessToken, {
|
||||
httpOnly: true,
|
||||
secure: config.cookie.secure,
|
||||
sameSite: config.cookie.sameSite,
|
||||
path: "/",
|
||||
maxAge: 15 * 60,
|
||||
domain: config.cookie.domain,
|
||||
})
|
||||
.setCookie("refreshToken", refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: config.cookie.secure,
|
||||
sameSite: config.cookie.sameSite,
|
||||
path: "/",
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
domain: config.cookie.domain,
|
||||
});
|
||||
|
||||
console.log(`✅ User ${user.username} logged in successfully`);
|
||||
|
||||
// Redirect to frontend
|
||||
return reply.redirect(`${config.cors.origin}/`);
|
||||
} catch (error) {
|
||||
console.error("❌ Steam callback error:", error);
|
||||
return reply.status(500).send({
|
||||
error: "AuthenticationError",
|
||||
message: "Steam authentication failed",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user
|
||||
fastify.get(
|
||||
"/me",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
// Remove sensitive data
|
||||
const user = request.user.toObject();
|
||||
delete user.twoFactor.secret;
|
||||
delete user.email.emailToken;
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
user: user,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Decode JWT token (for debugging - shows what's in the token)
|
||||
fastify.get("/decode-token", async (request, reply) => {
|
||||
try {
|
||||
let token = null;
|
||||
|
||||
// Try to get token from Authorization header
|
||||
const authHeader = request.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
token = authHeader.substring(7);
|
||||
}
|
||||
|
||||
// If not in header, try cookies
|
||||
if (!token && request.cookies && request.cookies.accessToken) {
|
||||
token = request.cookies.accessToken;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return reply.status(400).send({
|
||||
error: "NoToken",
|
||||
message: "No token provided. Send in Authorization header or cookie.",
|
||||
});
|
||||
}
|
||||
|
||||
// Import JWT utilities
|
||||
const { decodeToken } = await import("../utils/jwt.js");
|
||||
const decoded = decodeToken(token);
|
||||
|
||||
if (!decoded) {
|
||||
return reply.status(400).send({
|
||||
error: "InvalidToken",
|
||||
message: "Could not decode token",
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
decoded: decoded,
|
||||
message: "This shows what data is stored in your JWT token",
|
||||
});
|
||||
} catch (error) {
|
||||
return reply.status(500).send({
|
||||
error: "DecodeError",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh access token using refresh token
|
||||
fastify.post(
|
||||
"/refresh",
|
||||
{
|
||||
preHandler: verifyRefreshTokenMiddleware,
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const user = request.user;
|
||||
|
||||
// Generate new token pair
|
||||
const { accessToken, refreshToken } = generateTokenPair(user);
|
||||
|
||||
// Update existing session with new tokens
|
||||
try {
|
||||
const oldSession = await Session.findOne({
|
||||
refreshToken: request.refreshToken,
|
||||
userId: user._id,
|
||||
});
|
||||
|
||||
if (oldSession) {
|
||||
oldSession.token = accessToken;
|
||||
oldSession.refreshToken = refreshToken;
|
||||
oldSession.lastActivity = new Date();
|
||||
await oldSession.save();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update session:", error);
|
||||
}
|
||||
|
||||
// Set new cookies
|
||||
reply
|
||||
.setCookie("accessToken", accessToken, {
|
||||
httpOnly: true,
|
||||
secure: config.cookie.secure,
|
||||
sameSite: config.cookie.sameSite,
|
||||
path: "/",
|
||||
maxAge: 15 * 60, // 15 minutes
|
||||
domain: config.cookie.domain,
|
||||
})
|
||||
.setCookie("refreshToken", refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: config.cookie.secure,
|
||||
sameSite: config.cookie.sameSite,
|
||||
path: "/",
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
domain: config.cookie.domain,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Tokens refreshed successfully",
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Token refresh error:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to refresh tokens",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Logout - clear cookies and invalidate session
|
||||
fastify.post(
|
||||
"/logout",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
// Deactivate current session
|
||||
try {
|
||||
const session = await Session.findOne({
|
||||
token: request.token,
|
||||
userId: request.user._id,
|
||||
});
|
||||
|
||||
if (session) {
|
||||
await session.deactivate();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to deactivate session:", error);
|
||||
}
|
||||
|
||||
// Clear cookies
|
||||
reply
|
||||
.clearCookie("accessToken", {
|
||||
path: "/",
|
||||
domain: config.cookie.domain,
|
||||
})
|
||||
.clearCookie("refreshToken", {
|
||||
path: "/",
|
||||
domain: config.cookie.domain,
|
||||
});
|
||||
|
||||
console.log(`👋 User ${request.user.username} logged out`);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Logged out successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to logout",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Verify access token endpoint (useful for checking if token is still valid)
|
||||
fastify.get(
|
||||
"/verify",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
return reply.send({
|
||||
success: true,
|
||||
valid: true,
|
||||
userId: request.user._id,
|
||||
steamId: request.user.steamId,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user