From fd442c2f8b0fb01e40d5bfa1bcccedc5dc098a8e Mon Sep 17 00:00:00 2001 From: Jafeng <2998840497@qq.com> Date: Thu, 13 Nov 2025 09:33:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:token=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/models/refresh-token.model.ts | 22 +++++++ apps/backend/src/modules/auth/auth.service.ts | 60 +++++++++++++------ 2 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 apps/backend/src/models/refresh-token.model.ts diff --git a/apps/backend/src/models/refresh-token.model.ts b/apps/backend/src/models/refresh-token.model.ts new file mode 100644 index 0000000..dead57f --- /dev/null +++ b/apps/backend/src/models/refresh-token.model.ts @@ -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; + +export const RefreshTokenModel = + mongoose.models.RefreshToken ?? mongoose.model('RefreshToken', refreshTokenSchema); + diff --git a/apps/backend/src/modules/auth/auth.service.ts b/apps/backend/src/modules/auth/auth.service.ts index d2209ce..0074b29 100644 --- a/apps/backend/src/modules/auth/auth.service.ts +++ b/apps/backend/src/modules/auth/auth.service.ts @@ -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(); - type UserDocumentWithId = UserDocument & { _id: mongoose.Types.ObjectId }; const toAuthUser = (doc: UserDocumentWithId): AuthUser => ({ @@ -58,7 +59,9 @@ async function getUserById(id: string): Promise { 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 => { 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() + .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) {