Spaces:
Paused
Paused
File size: 5,627 Bytes
c904036 6502ceb c904036 6502ceb c904036 6502ceb c904036 6502ceb c904036 | 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 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 | const mongoose = require('mongoose');
const crypto = require('crypto');
/**
* Session Schema
* Stores active user sessions with refresh tokens for session management
* Supports: Login History, Active Sessions, Token Refresh
*/
const sessionSchema = new mongoose.Schema({
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: false
},
// Refresh token (hashed for security)
refreshToken: {
type: String,
required: true,
unique: true
},
// Device & Browser Info
device: {
type: String,
default: 'Unknown Device'
},
browser: {
type: String,
default: 'Unknown Browser'
},
os: {
type: String,
default: 'Unknown OS'
},
userAgent: {
type: String,
default: ''
},
// Location Info (from IP)
ipAddress: {
type: String,
default: ''
},
location: {
city: { type: String, default: '' },
region: { type: String, default: '' },
country: { type: String, default: '' },
formatted: { type: String, default: 'Unknown Location' }
},
// Session Status
isActive: {
type: Boolean,
default: true,
index: false
},
// Timestamps
createdAt: {
type: Date,
default: Date.now,
index: false
},
lastActivity: {
type: Date,
default: Date.now
},
expiresAt: {
type: Date,
required: true,
index: false
},
// Revocation info
revokedAt: {
type: Date
},
revokedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
revokeReason: {
type: String,
enum: ['logout', 'password_change', 'security', 'admin_action', 'expired', 'manual'],
default: null
}
}, {
timestamps: true
});
// Indexes for efficient queries
sessionSchema.index({ userId: 1, isActive: 1 });
sessionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); // TTL index - auto-delete expired sessions
// Instance method: Check if session is valid
sessionSchema.methods.isValid = function() {
return this.isActive && this.expiresAt > new Date();
};
// Instance method: Revoke session
sessionSchema.methods.revoke = async function(reason = 'manual', revokedBy = null) {
this.isActive = false;
this.revokedAt = new Date();
this.revokedBy = revokedBy;
this.revokeReason = reason;
return this.save();
};
// Instance method: Update last activity
sessionSchema.methods.touch = async function() {
this.lastActivity = new Date();
return this.save();
};
// Static method: Create new session with refresh token
// Handles duplicate sessions for same device - replaces existing session
sessionSchema.statics.createSession = async function(userId, deviceInfo, expiresInDays = 7) {
const refreshToken = crypto.randomBytes(64).toString('hex');
const hashedToken = crypto.createHash('sha256').update(refreshToken).digest('hex');
// Check for existing active session from same device
// Match by device + browser + OS combination to identify same device
const existingSession = await this.findOne({
userId,
isActive: true,
device: deviceInfo.device || 'Unknown Device',
browser: deviceInfo.browser || 'Unknown Browser',
os: deviceInfo.os || 'Unknown OS'
});
// If same device already has a session, revoke it first
if (existingSession) {
await existingSession.revoke('manual');
}
const session = await this.create({
userId,
refreshToken: hashedToken,
device: deviceInfo.device || 'Unknown Device',
browser: deviceInfo.browser || 'Unknown Browser',
os: deviceInfo.os || 'Unknown OS',
userAgent: deviceInfo.userAgent || '',
ipAddress: deviceInfo.ipAddress || '',
location: deviceInfo.location || { formatted: 'Unknown Location' },
expiresAt: new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000)
});
// Return unhashed token for client, session for reference
return { session, refreshToken };
};
// Static method: Find session by refresh token
sessionSchema.statics.findByRefreshToken = async function(refreshToken) {
const hashedToken = crypto.createHash('sha256').update(refreshToken).digest('hex');
return this.findOne({
refreshToken: hashedToken,
isActive: true,
expiresAt: { $gt: new Date() }
}).populate('userId', 'name email roles isVerified profilePhoto');
};
// Static method: Get active sessions for user
sessionSchema.statics.getActiveSessions = async function(userId) {
return this.find({
userId,
isActive: true,
expiresAt: { $gt: new Date() }
}).sort({ lastActivity: -1 });
};
// Static method: Revoke all sessions for user (except current)
sessionSchema.statics.revokeAllExcept = async function(userId, currentSessionId, reason = 'security') {
return this.updateMany(
{
userId,
_id: { $ne: currentSessionId },
isActive: true
},
{
isActive: false,
revokedAt: new Date(),
revokeReason: reason
}
);
};
// Static method: Revoke all sessions for user
sessionSchema.statics.revokeAll = async function(userId, reason = 'logout') {
return this.updateMany(
{ userId, isActive: true },
{
isActive: false,
revokedAt: new Date(),
revokeReason: reason
}
);
};
// Static method: Cleanup expired sessions (run periodically)
sessionSchema.statics.cleanupExpired = async function() {
return this.deleteMany({
$or: [
{ expiresAt: { $lt: new Date() } },
{ isActive: false, revokedAt: { $lt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) } } // Delete revoked sessions after 30 days
]
});
};
module.exports = mongoose.model('Session', sessionSchema);
|