Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ lerna-debug.log*
docker-compose.yml
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

.agent
# Runtime data
pids
*.pid
Expand Down
6 changes: 4 additions & 2 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ const getEnvironment = (): Environment => {
interface Config {
PORT: number;
ENVIRONMENT: Environment,
JWT_SECRET: string;
JWT_REFRESH_SECRET: string;
REFRESH_TOKEN_HASH_SECRET: string;
SESSION_SECRET: string;
OTP_SECRET_KEY: string;
REDIS_URL: string;
Expand Down Expand Up @@ -58,7 +59,8 @@ interface Config {
const config: Config = {
PORT: parseInt(process.env.PORT || '3000'),
ENVIRONMENT: getEnvironment(),
JWT_SECRET: process.env.JWT_SECRET!,
JWT_REFRESH_SECRET: process.env.JWT_REFRESH_SECRET!,
REFRESH_TOKEN_HASH_SECRET: process.env.REFRESH_TOKEN_HASH_SECRET!,
SESSION_SECRET: process.env.SESSION_SECRET!,
OTP_SECRET_KEY: process.env.OTP_SECRET_KEY!,
REDIS_URL: process.env.REDIS_URL || 'redis://localhost:6379',
Expand Down
143 changes: 143 additions & 0 deletions src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import type { Context } from "hono";
import res from "@/utils/response";
import authService from "@/services/auth.service";
import type { InferSchemaType } from "@/utils/validation";
import authDtoValidation from "@/validation/auth.validation";

/**
* POST /auth/refresh
* Refresh access token using refresh token
*/
const refreshToken = async (c: Context) => {
try {
type RefreshTokenBody = InferSchemaType<typeof authDtoValidation.refreshTokenValidation>;
const { refresh_token } = c.get('validated') as RefreshTokenBody;

// Get metadata
const ip = c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || c.env?.ip;
const userAgent = c.req.header("user-agent");

// Rotate the refresh token
const tokenPair = await authService.rotateRefreshToken(refresh_token, { ip, userAgent });

return res.SuccessResponse(c, 200, {
message: "Token refreshed successfully",
data: {
access_token: tokenPair.accessToken,
refresh_token: tokenPair.refreshToken,
expires_at: tokenPair.accessTokenExpiresAt,
refresh_expires_at: tokenPair.refreshTokenExpiresAt,
},
});
} catch (error) {
console.error("Refresh token error:", error);
return res.FailureResponse(c, 401, {
message: error instanceof Error ? error.message : "Invalid or expired refresh token",
});
}
};

/**
* POST /auth/revoke
* Revoke a specific refresh token (logout from single device)
*/
const revokeToken = async (c: Context) => {
try {
type RevokeTokenBody = InferSchemaType<typeof authDtoValidation.revokeTokenValidation>;
const { refresh_token } = c.get('validated') as RevokeTokenBody;

const success = await authService.revokeRefreshToken(refresh_token);

if (!success) {
return res.FailureResponse(c, 404, {
message: "Refresh token not found",
});
}

return res.SuccessResponse(c, 200, {
message: "Refresh token revoked successfully",
data: {},
});
} catch (error) {
console.error("Revoke token error:", error);
return res.FailureResponse(c, 500, {
message: "Failed to revoke token",
});
}
};

/**
* POST /auth/revoke-all
* Revoke all refresh tokens for the authenticated user (logout from all devices)
*/
const revokeAllTokens = async (c: Context) => {
try {
const user = c.get('user');
if (!user) {
return res.FailureResponse(c, 401, {
message: "User not authenticated.",
});
}

const count = await authService.revokeAllUserTokens(user.id);

return res.SuccessResponse(c, 200, {
message: "All refresh tokens revoked successfully",
data: {
revoked_count: count,
},
});
} catch (error) {
return res.FailureResponse(c, 500, {
message: "Failed to revoke all tokens",
});
}
};

/**
* GET /auth/sessions
* Get all active refresh tokens (sessions) for the authenticated user
*/
const getActiveSessions = async (c: Context) => {
try {
const user = c.get('user');
if (!user) {
return res.FailureResponse(c, 401, {
message: "User not authenticated.",
});
}

const sessions = await authService.getActiveRefreshTokens(user.id);

// Map to user-friendly format (don't expose token hashes)
const sessionData = sessions.map(session => ({
id: session.id,
refresh_token: session.token_hash,
created_at: session.created_at,
expires_at: session.expires_at,
last_used_at: session.last_used_at,
ip: session.ip,
user_agent: session.user_agent,
}));

return res.SuccessResponse(c, 200, {
message: "Active sessions retrieved successfully",
data: {
sessions: sessionData,
total: sessionData.length,
},
});
} catch (error) {
console.error("Get active sessions error:", error);
return res.FailureResponse(c, 500, {
message: "Failed to retrieve active sessions",
});
}
};

export default {
refreshToken,
revokeToken,
revokeAllTokens,
getActiveSessions,
};
27 changes: 20 additions & 7 deletions src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import userDtoValidation from "@/validation/user.validation";
import userRepository from "@/repository/user.repository";
import userService from "@/services/user.service";
import type { IUserAttributes } from "@/models/User.model";
import { getSessionData, getValidPinSession, setSessionData, type PinSessionData } from "@/core/session";
import { getValidPinSession, setSessionData, type PinSessionData } from "@/core/session";


/**
Expand Down Expand Up @@ -49,26 +49,39 @@ const Login = async (c: Context) => {
try {
type LoginBody = InferSchemaType<typeof userDtoValidation.loginValidation>;
const { email, password } = c.get('validated') as LoginBody;
// Check if user exists and account status

// Check if user exists
const user = await userRepository.findUserByEmail(email);
if (!user) {
return res.FailureResponse(c, 400, {
message: "User not found",
});
}
// Check if password is correct
const isPasswordCorrect = await userService.verifyPassword(password, user.password_hash);

// Verify password
const isPasswordCorrect = await userService.verifyPassword(password, user.password_hash);
if (!isPasswordCorrect) {
return res.FailureResponse(c, 400, {
message: "Invalid password",
});
}
// Generate session
const session = await userService.generateSession(user);

// Get metadata for token generation
const ip = c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || c.env?.ip;
const userAgent = c.req.header("user-agent");

// Generate session with refresh token
const session = await userService.generateSession(user, { ip, userAgent });

return res.SuccessResponse(c, 200, {
message: "Login successful",
data: { jwt: session, user: user },
data: {
access_token: session.jwt_token,
refresh_token: session.refresh_token,
expires_at: session.expiresAt,
refresh_expires_at: session.refreshExpiresAt,
user: user
},
});

} catch (error) {
Expand Down
1 change: 0 additions & 1 deletion src/core/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import constants from '@/global/constants';
* Cons: Limited by cookie size (~4KB), all data sent with every request
*
*/

const cookieOptions: SessionOptions['cookieOptions'] = {
// domain: config.ENVIRONMENT === 'development' ? 'localhost' : undefined,
path: '/',
Expand Down
8 changes: 5 additions & 3 deletions src/global/constants.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@



const OTP_VALID_DURATION = 10 * 60 * 1000; // 10 MINS in milliseconds
const RESEND_COOLDOWN_MINUTES = 1; // one minuite
const TOKEN_EXPIRY_TIME = 360 * 24 * 60 * 60; // 365 DAYS in seconds
const ACCESS_TOKEN_EXPIRY_TIME = 15 * 60; // 15 MINUTES in seconds
const REFRESH_TOKEN_EXPIRY_TIME = 30 * 24 * 60 * 60; // 30 DAYS in seconds
const INVITATION_EXPIRY_TIME = 30 * 24 * 60 * 60; // 30 DAYS in seconds
const VERIFY_EMAIL_EXPIRY_TIME = 7 * 24 * 60 * 60; // 7 DAYS in seconds
const SALT_ROUNDS = 10;
Expand All @@ -26,7 +26,8 @@ export default {
SALT_ROUNDS,
MAX_FILE_UPLOAD,
MAX_CPU_USAGE,
TOKEN_EXPIRY_TIME,
ACCESS_TOKEN_EXPIRY_TIME,
REFRESH_TOKEN_EXPIRY_TIME,
VERIFY_EMAIL_EXPIRY_TIME,
OTP_VALID_DURATION,
MAX_PAGINATION_LIMIT,
Expand All @@ -38,3 +39,4 @@ export default {
SHARE_EXPIRY_TIME,
PIN_SESSION_DURATION_MS,
}

6 changes: 4 additions & 2 deletions src/global/redis-constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
const USER_SESSION_PREFIX = 'session:user:';
// const USER_SESSION_PREFIX = 'session:user:';
const ACCESS_TOKEN_PREFIX = 'access:token:';

export default {
USER_SESSION_PREFIX
// USER_SESSION_PREFIX,
ACCESS_TOKEN_PREFIX
}
18 changes: 6 additions & 12 deletions src/middleware/auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import jwt from "@/utils/jwt-token";
import db from "@/config/database";
import redisConn from "@/config/redis.config";
import redisConstants from "@/global/redis-constants";
import { type ISessionData } from "@/types/hono";
import authService from "@/services/user.service";
import { getValidPinSession } from "@/core/session";
import crypto from "crypto";

Expand All @@ -16,25 +14,21 @@ const verifyBearerToken = async (c: Context, token: string, next: Next) => {
try {
const jwt_decode = jwt.verifyJwtToken(token);

// 2️⃣ Check Redis session
// Check if access token exists in Redis (fast validation)
const client = redisConn.getClient();
const sessionKey = `${redisConstants.USER_SESSION_PREFIX}${jwt_decode.id}:${token}`;
const sessionData = await client.get(sessionKey);
const redisKey = `${redisConstants.ACCESS_TOKEN_PREFIX}${jwt_decode.id}:${token}`;
const tokenData = await client.get(redisKey);

if (!sessionData) {
if (!tokenData) {
return res.FailureResponse(c, 401, { message: "Session expired or invalid." });
}

const { login_details } = authService.parseJson<ISessionData>(sessionData);

// Ensure the token matches the stored session
if (login_details.session_token !== token || !login_details.is_active) {
return res.FailureResponse(c, 401, { message: "Invalid or inactive session." });
}
// Verify user exists in database
const find_user = await db.User.findOne({ where: { id: jwt_decode.id }, raw: true });
if (!find_user) {
return res.FailureResponse(c, 401, { message: "Login expired." });
}

// Attach the user object to the context for downstream handlers
c.set("user", find_user);
await next();
Expand Down
Loading