feat:token持久化
This commit is contained in:
22
apps/backend/src/models/refresh-token.model.ts
Normal file
22
apps/backend/src/models/refresh-token.model.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import mongoose, { type InferSchemaType } from 'mongoose';
|
||||
|
||||
const refreshTokenSchema = new mongoose.Schema(
|
||||
{
|
||||
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true, required: true },
|
||||
tokenHash: { type: String, unique: true, index: true, required: true },
|
||||
familyId: { type: String, index: true },
|
||||
createdAt: { type: Date, default: () => new Date(), required: true },
|
||||
expiresAt: { type: Date, required: true },
|
||||
revoked: { type: Boolean, default: false },
|
||||
replacedBy: { type: String },
|
||||
ip: { type: String },
|
||||
userAgent: { type: String }
|
||||
},
|
||||
{ timestamps: false }
|
||||
);
|
||||
|
||||
export type RefreshTokenDocument = InferSchemaType<typeof refreshTokenSchema>;
|
||||
|
||||
export const RefreshTokenModel =
|
||||
mongoose.models.RefreshToken ?? mongoose.model('RefreshToken', refreshTokenSchema);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import mongoose from 'mongoose';
|
||||
import crypto from 'node:crypto';
|
||||
import { env } from '../../config/env.js';
|
||||
import { UserModel, type UserDocument } from '../../models/user.model.js';
|
||||
import { RefreshTokenModel, type RefreshTokenDocument } from '../../models/refresh-token.model.js';
|
||||
import { logger } from '../../config/logger.js';
|
||||
|
||||
const ACCESS_TOKEN_TTL = '15m';
|
||||
const REFRESH_TOKEN_TTL = '7d';
|
||||
// Increase token validity as requested
|
||||
const ACCESS_TOKEN_TTL = '60m';
|
||||
const REFRESH_TOKEN_TTL = '30d';
|
||||
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
@@ -25,8 +28,6 @@ interface AuthTokens {
|
||||
refreshExpiresIn: number;
|
||||
}
|
||||
|
||||
const refreshTokenStore = new Map<string, { userId: string; expiresAt: Date }>();
|
||||
|
||||
type UserDocumentWithId = UserDocument & { _id: mongoose.Types.ObjectId };
|
||||
|
||||
const toAuthUser = (doc: UserDocumentWithId): AuthUser => ({
|
||||
@@ -58,7 +59,9 @@ async function getUserById(id: string): Promise<AuthUser | null> {
|
||||
return doc ? toAuthUser(doc) : null;
|
||||
}
|
||||
|
||||
const signTokens = (user: AuthUser): AuthTokens => {
|
||||
const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
const signTokens = async (user: AuthUser): Promise<AuthTokens> => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const accessToken = jwt.sign(
|
||||
{
|
||||
@@ -79,13 +82,23 @@ const signTokens = (user: AuthUser): AuthTokens => {
|
||||
{ expiresIn: REFRESH_TOKEN_TTL }
|
||||
);
|
||||
|
||||
const refreshExpiresIn = jwt.decode(refreshToken, { json: true })?.exp ?? nowSeconds + 7 * 24 * 60 * 60;
|
||||
refreshTokenStore.set(refreshToken, { userId: user.id, expiresAt: new Date(refreshExpiresIn * 1000) });
|
||||
const refreshExpiresIn = jwt.decode(refreshToken, { json: true })?.exp ?? nowSeconds + 30 * 24 * 60 * 60;
|
||||
// persist refresh token hash in DB
|
||||
try {
|
||||
await RefreshTokenModel.create({
|
||||
userId: new mongoose.Types.ObjectId(user.id),
|
||||
tokenHash: hashToken(refreshToken),
|
||||
familyId: user.id,
|
||||
expiresAt: new Date(refreshExpiresIn * 1000)
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn({ e }, 'Failed to persist refresh token');
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: nowSeconds + 15 * 60,
|
||||
expiresIn: nowSeconds + 60 * 60,
|
||||
refreshExpiresIn
|
||||
};
|
||||
};
|
||||
@@ -115,7 +128,7 @@ export async function registerUser(input: {
|
||||
}
|
||||
|
||||
const user = toAuthUser(createdDoc);
|
||||
const tokens = signTokens(user);
|
||||
const tokens = await signTokens(user);
|
||||
return { user: profileFromUser(user), tokens };
|
||||
}
|
||||
|
||||
@@ -130,7 +143,7 @@ export async function authenticateUser(input: { email: string; password: string
|
||||
throw Object.assign(new Error('Invalid credentials'), { statusCode: 401 });
|
||||
}
|
||||
|
||||
const tokens = signTokens(user);
|
||||
const tokens = await signTokens(user);
|
||||
return { user: profileFromUser(user), tokens };
|
||||
}
|
||||
|
||||
@@ -138,19 +151,28 @@ export async function refreshSession(refreshToken: string) {
|
||||
const refreshTokenSecret = env.JWT_REFRESH_SECRET ?? env.JWT_SECRET;
|
||||
try {
|
||||
const payload = jwt.verify(refreshToken, refreshTokenSecret) as { sub: string };
|
||||
const stored = refreshTokenStore.get(refreshToken);
|
||||
|
||||
if (!stored || stored.userId !== payload.sub) {
|
||||
const tokenHash = hashToken(refreshToken);
|
||||
const record = await RefreshTokenModel.findOne({ tokenHash, userId: payload.sub, revoked: { $ne: true } })
|
||||
.lean<RefreshTokenDocument & { _id: mongoose.Types.ObjectId }>()
|
||||
.exec();
|
||||
if (!record) {
|
||||
throw new Error('Refresh token revoked');
|
||||
}
|
||||
if (record.expiresAt.getTime() <= Date.now()) {
|
||||
throw new Error('Refresh token expired');
|
||||
}
|
||||
|
||||
const user = await getUserById(payload.sub);
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
refreshTokenStore.delete(refreshToken);
|
||||
const tokens = signTokens(user);
|
||||
// rotate: revoke old and issue new
|
||||
await RefreshTokenModel.updateOne({ tokenHash }, { $set: { revoked: true } }).exec();
|
||||
const tokens = await signTokens(user);
|
||||
await RefreshTokenModel.updateOne(
|
||||
{ tokenHash },
|
||||
{ $set: { replacedBy: hashToken(tokens.refreshToken) } }
|
||||
).exec();
|
||||
return { user: profileFromUser(user), tokens };
|
||||
} catch (error) {
|
||||
logger.warn({ error }, 'Failed to refresh session');
|
||||
@@ -159,7 +181,11 @@ export async function refreshSession(refreshToken: string) {
|
||||
}
|
||||
|
||||
export async function revokeRefreshToken(refreshToken: string) {
|
||||
refreshTokenStore.delete(refreshToken);
|
||||
try {
|
||||
await RefreshTokenModel.updateOne({ tokenHash: hashToken(refreshToken) }, { $set: { revoked: true } }).exec();
|
||||
} catch (e) {
|
||||
logger.warn({ e }, 'Failed to revoke refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestPasswordReset(email: string) {
|
||||
|
||||
Reference in New Issue
Block a user