working full end-to-end with encryption
This commit is contained in:
231
src/routes/auth.routes.ts
Normal file
231
src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { prisma } from '../db';
|
||||
import axios from 'axios';
|
||||
import crypto from 'crypto';
|
||||
import { encrypt, decrypt } from '../utils/encryption';
|
||||
|
||||
const router = express.Router();
|
||||
const SALT_ROUNDS = 12;
|
||||
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
// Middleware to check if user is authenticated
|
||||
export async function requireAuth(req: any, res: any, next: any) {
|
||||
const sessionToken = req.cookies?.sessionToken;
|
||||
|
||||
if (!sessionToken) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { token: sessionToken },
|
||||
include: { user: true }
|
||||
});
|
||||
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
return res.status(401).json({ error: 'Session expired' });
|
||||
}
|
||||
|
||||
req.user = session.user;
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Authentication error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Register new user
|
||||
router.post('/register', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password required' });
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
}
|
||||
|
||||
const normalizedUsername = username.toLowerCase().trim();
|
||||
|
||||
try {
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
username: {
|
||||
equals: normalizedUsername,
|
||||
mode: 'insensitive'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(409).json({ error: 'Username already exists' });
|
||||
}
|
||||
|
||||
// Verify credentials with Skyward via port 3000 API
|
||||
console.log('Verifying Skyward credentials...');
|
||||
try {
|
||||
const authCheck = await axios.post('http://localhost:3000/check-auth', {
|
||||
username,
|
||||
password
|
||||
}, {
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
if (!authCheck.data.success) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid Skyward credentials. Please verify your username and password.'
|
||||
});
|
||||
}
|
||||
console.log('Skyward credentials verified successfully');
|
||||
} catch (authError: any) {
|
||||
if (authError.response?.status === 401) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid Skyward credentials. Please verify your username and password.'
|
||||
});
|
||||
}
|
||||
console.error('Skyward auth check failed:', authError.message);
|
||||
return res.status(500).json({
|
||||
error: 'Unable to verify credentials with Skyward. Please try again.'
|
||||
});
|
||||
}
|
||||
|
||||
// Hash password for login authentication
|
||||
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
|
||||
// Encrypt password for Skyward API calls
|
||||
const encryptedSkywardPassword = encrypt(password);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: normalizedUsername,
|
||||
password: hashedPassword,
|
||||
skywardPassword: encryptedSkywardPassword
|
||||
}
|
||||
});
|
||||
|
||||
console.log('User created successfully');
|
||||
|
||||
const sessionToken = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + SESSION_DURATION);
|
||||
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
token: sessionToken,
|
||||
userId: user.id,
|
||||
expiresAt
|
||||
}
|
||||
});
|
||||
|
||||
res.cookie('sessionToken', sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: false, // set to true in production
|
||||
sameSite: 'lax',
|
||||
maxAge: SESSION_DURATION
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
return res.status(500).json({ error: 'Registration failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login existing user
|
||||
router.post('/login', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password required' });
|
||||
}
|
||||
|
||||
const normalizedUsername = username.toLowerCase().trim();
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username: {
|
||||
equals: normalizedUsername,
|
||||
mode: 'insensitive'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const sessionToken = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + SESSION_DURATION);
|
||||
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
token: sessionToken,
|
||||
userId: user.id,
|
||||
expiresAt
|
||||
}
|
||||
});
|
||||
|
||||
res.cookie('sessionToken', sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'lax',
|
||||
maxAge: SESSION_DURATION
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.post('/logout', async (req, res) => {
|
||||
const sessionToken = req.cookies?.sessionToken;
|
||||
|
||||
if (sessionToken) {
|
||||
try {
|
||||
await prisma.session.delete({
|
||||
where: { token: sessionToken }
|
||||
});
|
||||
} catch (error) {
|
||||
// Session might not exist
|
||||
}
|
||||
}
|
||||
|
||||
res.clearCookie('sessionToken');
|
||||
return res.json({ success: true });
|
||||
});
|
||||
|
||||
// Check auth status
|
||||
router.get('/me', requireAuth, async (req: any, res) => {
|
||||
return res.json({
|
||||
user: {
|
||||
id: req.user.id,
|
||||
username: req.user.username
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -2,13 +2,24 @@ import { Router } from 'express';
|
||||
import { getGradesForUser, getFetchHistory, getFinalGrades } from '../services/grades.service';
|
||||
import { syncUserGrades } from '../services/sync.service';
|
||||
import { prisma } from '../db';
|
||||
import { requireAuth } from './auth.routes';
|
||||
import { getSkywardCredentials } from '../utils/user-helpers';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get all grades for a user
|
||||
router.get('/users/:username/grades', async (req, res) => {
|
||||
// Get all grades for a user (protected)
|
||||
router.get('/users/:username/grades', requireAuth, async (req: any, res) => {
|
||||
try {
|
||||
const { username } = req.params;
|
||||
|
||||
// Ensure user can only access their own grades
|
||||
if (req.user.username !== username) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await getGradesForUser(username);
|
||||
|
||||
res.json({
|
||||
@@ -28,10 +39,19 @@ router.get('/users/:username/grades', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get classes for a user
|
||||
router.get('/users/:username/classes', async (req, res) => {
|
||||
// Get classes for a user (protected)
|
||||
router.get('/users/:username/classes', requireAuth, async (req: any, res) => {
|
||||
try {
|
||||
const { username } = req.params;
|
||||
|
||||
// Ensure user can only access their own classes
|
||||
if (req.user.username !== username) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
include: {
|
||||
@@ -69,11 +89,19 @@ router.get('/users/:username/classes', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get assignments for a specific class
|
||||
router.get('/users/:username/classes/:category/assignments', async (req, res) => {
|
||||
// Get assignments for a specific class (protected)
|
||||
router.get('/users/:username/classes/:category/assignments', requireAuth, async (req: any, res) => {
|
||||
try {
|
||||
const { username, category } = req.params;
|
||||
|
||||
// Ensure user can only access their own assignments
|
||||
if (req.user.username !== username) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
@@ -85,7 +113,6 @@ router.get('/users/:username/classes/:category/assignments', async (req, res) =>
|
||||
});
|
||||
}
|
||||
|
||||
// Find all classes with this category (there might be multiple)
|
||||
const classes = await prisma.class.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -105,7 +132,6 @@ router.get('/users/:username/classes/:category/assignments', async (req, res) =>
|
||||
});
|
||||
}
|
||||
|
||||
// Return all classes with this category
|
||||
res.json({
|
||||
success: true,
|
||||
category,
|
||||
@@ -126,10 +152,19 @@ router.get('/users/:username/classes/:category/assignments', async (req, res) =>
|
||||
}
|
||||
});
|
||||
|
||||
// Get fetch history for a user
|
||||
router.get('/users/:username/history', async (req, res) => {
|
||||
// Get fetch history for a user (protected)
|
||||
router.get('/users/:username/history', requireAuth, async (req: any, res) => {
|
||||
try {
|
||||
const { username } = req.params;
|
||||
|
||||
// Ensure user can only access their own history
|
||||
if (req.user.username !== username) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
const history = await getFetchHistory(username);
|
||||
|
||||
res.json({
|
||||
@@ -147,10 +182,19 @@ router.get('/users/:username/history', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get final grades for a user
|
||||
router.get('/users/:username/final-grades', async (req, res) => {
|
||||
// Get final grades for a user (protected)
|
||||
router.get('/users/:username/final-grades', requireAuth, async (req: any, res) => {
|
||||
try {
|
||||
const { username } = req.params;
|
||||
|
||||
// Ensure user can only access their own grades
|
||||
if (req.user.username !== username) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
const finalGrades = await getFinalGrades(username);
|
||||
|
||||
res.json({
|
||||
@@ -168,23 +212,21 @@ router.get('/users/:username/final-grades', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Manual sync for one user
|
||||
router.post('/sync/manual', async (req, res) => {
|
||||
// Manual sync for authenticated user (protected)
|
||||
router.post('/sync/manual', requireAuth, async (req: any, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Username and password are required',
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[Manual Sync] User ${req.user.username} requested manual sync`);
|
||||
|
||||
// Get decrypted Skyward credentials
|
||||
const { username, password } = await getSkywardCredentials(req.user.id);
|
||||
|
||||
// Sync grades using the decrypted password
|
||||
const result = await syncUserGrades(username, password);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Manual Sync] Error:`, errorMsg);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { getOverallStats, getUserStats, getClassStats } from '../services/stats.service';
|
||||
import { requireAuth } from './auth.routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Overall system stats
|
||||
router.get('/overall', async (req, res) => {
|
||||
// Overall system stats (protected)
|
||||
router.get('/overall', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const stats = await getOverallStats();
|
||||
res.json({
|
||||
@@ -20,10 +21,19 @@ router.get('/overall', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// User-specific stats
|
||||
router.get('/users/:username', async (req, res) => {
|
||||
// User-specific stats (protected)
|
||||
router.get('/users/:username', requireAuth, async (req: any, res) => {
|
||||
try {
|
||||
const { username } = req.params;
|
||||
|
||||
// Ensure user can only access their own stats
|
||||
if (req.user.username !== username) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
const stats = await getUserStats(username);
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -38,10 +48,19 @@ router.get('/users/:username', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Class-specific stats
|
||||
router.get('/users/:username/classes/:category', async (req, res) => {
|
||||
// Class-specific stats (protected)
|
||||
router.get('/users/:username/classes/:category', requireAuth, async (req: any, res) => {
|
||||
try {
|
||||
const { username, category } = req.params;
|
||||
|
||||
// Ensure user can only access their own stats
|
||||
if (req.user.username !== username) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
const stats = await getClassStats(username, category);
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -1,58 +1,15 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../db';
|
||||
import { getAllUsers, deleteUser } from '../services/grades.service';
|
||||
import { requireAuth } from './auth.routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Register a new user
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
// NOTE: User registration is now handled by /api/auth/register
|
||||
// This old endpoint is kept for backward compatibility but should be removed
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Username and password are required',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: 'Username already exists',
|
||||
});
|
||||
}
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: { username, password },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get all users
|
||||
router.get('/', async (req, res) => {
|
||||
// Get all users (protected, admin only - you can add admin check later)
|
||||
router.get('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const users = await getAllUsers();
|
||||
res.json({
|
||||
@@ -69,10 +26,19 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Delete user
|
||||
router.delete('/:username', async (req, res) => {
|
||||
// Delete user (protected)
|
||||
router.delete('/:username', requireAuth, async (req: any, res) => {
|
||||
try {
|
||||
const { username } = req.params;
|
||||
|
||||
// Ensure user can only delete their own account
|
||||
if (req.user.username !== username) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await deleteUser(username);
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
Reference in New Issue
Block a user