Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79aca01bbf | ||
|
|
a87ca648dc |
No files matched your search
@@ -5,6 +5,7 @@ import axios from "axios";
|
||||
const initialState = {
|
||||
isLoading: false,
|
||||
authToken: "",
|
||||
refreshToken: "",
|
||||
user: "",
|
||||
success: null,
|
||||
msg: null,
|
||||
@@ -138,6 +139,7 @@ const handleAuthFulfilled = (state, action) => {
|
||||
state.success = action.payload.success;
|
||||
state.msg = action.payload.msg;
|
||||
state.authToken = action.payload.data.token;
|
||||
state.refreshToken = action.payload.data.refreshToken || "";
|
||||
state.user = action.payload.data.user;
|
||||
};
|
||||
const handleAuthRejected = (state, action) => {
|
||||
@@ -188,11 +190,15 @@ const authSlice = createSlice({
|
||||
reducers: {
|
||||
clearAuthState: (state) => {
|
||||
state.authToken = "";
|
||||
state.refreshToken = "";
|
||||
state.user = "";
|
||||
state.isLoading = false;
|
||||
state.success = true;
|
||||
state.msg = "Logged out successfully";
|
||||
},
|
||||
setAuthToken: (state, action) => {
|
||||
state.authToken = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
// Register thunk
|
||||
@@ -246,4 +252,4 @@ const authSlice = createSlice({
|
||||
});
|
||||
|
||||
export default authSlice.reducer;
|
||||
export const { clearAuthState } = authSlice.actions;
|
||||
export const { clearAuthState, setAuthToken } = authSlice.actions;
|
||||
@@ -2,7 +2,7 @@ import axios from "axios";
|
||||
import i18next from "i18next";
|
||||
const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL;
|
||||
const FALLBACK_BASE_URL = "http://localhost:5000/api/v1";
|
||||
import { clearAuthState } from "../Features/Auth/authSlice";
|
||||
import { clearAuthState, setAuthToken } from "../Features/Auth/authSlice";
|
||||
class NetworkService {
|
||||
constructor(store, dispatch, navigate) {
|
||||
this.store = store;
|
||||
@@ -26,7 +26,7 @@ class NetworkService {
|
||||
(config) => {
|
||||
const currentLanguage = i18next.language || "en";
|
||||
|
||||
const { authToken } = store.getState().auth;
|
||||
const { authToken, refreshToken } = store.getState().auth;
|
||||
|
||||
config.headers = {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
@@ -34,15 +34,36 @@ class NetworkService {
|
||||
...config.headers,
|
||||
};
|
||||
|
||||
// Only send refresh token to the refresh endpoint
|
||||
if (refreshToken && config.url?.includes("/auth/refresh")) {
|
||||
config.headers["x-refresh-token"] = refreshToken;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Track if we're currently refreshing to prevent multiple refresh calls
|
||||
this.isRefreshing = false;
|
||||
this.failedQueue = [];
|
||||
|
||||
const processQueue = (error, token = null) => {
|
||||
this.failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
} else {
|
||||
prom.resolve(token);
|
||||
}
|
||||
});
|
||||
this.failedQueue = [];
|
||||
};
|
||||
|
||||
this.axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
async (error) => {
|
||||
// Handle network errors (server unreachable)
|
||||
if (error.code === "ERR_NETWORK") {
|
||||
// Navigate to server unreachable page
|
||||
@@ -51,8 +72,66 @@ class NetworkService {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Handle authentication errors
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Handle 403 - server is telling us to request a new access token
|
||||
// Skip refresh logic for the refresh endpoint itself to prevent circular dependency
|
||||
const isRefreshEndpoint = originalRequest.url?.includes("/auth/refresh");
|
||||
if (
|
||||
error.response &&
|
||||
error.response.status === 403 &&
|
||||
!originalRequest._retry &&
|
||||
!isRefreshEndpoint
|
||||
) {
|
||||
// Check if we have a refresh token before attempting refresh
|
||||
const { refreshToken } = store.getState().auth;
|
||||
if (!refreshToken) {
|
||||
dispatch(clearAuthState());
|
||||
navigate("/login");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (this.isRefreshing) {
|
||||
// If already refreshing, queue this request
|
||||
return new Promise((resolve, reject) => {
|
||||
this.failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers["Authorization"] = `Bearer ${token}`;
|
||||
return this.axiosInstance(originalRequest);
|
||||
})
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
this.isRefreshing = true;
|
||||
|
||||
try {
|
||||
// Call refresh endpoint
|
||||
const response = await this.axiosInstance.post("/auth/refresh");
|
||||
const newToken = response.data.data.token;
|
||||
|
||||
// Update the auth token in store
|
||||
dispatch(setAuthToken(newToken));
|
||||
|
||||
// Process queued requests
|
||||
processQueue(null, newToken);
|
||||
|
||||
// Retry original request with new token
|
||||
originalRequest.headers["Authorization"] = `Bearer ${newToken}`;
|
||||
return this.axiosInstance(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// Refresh failed, clear auth and redirect to login
|
||||
processQueue(refreshError, null);
|
||||
dispatch(clearAuthState());
|
||||
navigate("/login");
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 401 - authentication failed (invalid/expired refresh token or no token)
|
||||
if (error.response && error.response.status === 401) {
|
||||
dispatch(clearAuthState());
|
||||
navigate("/login");
|
||||
|
||||
@@ -36,11 +36,11 @@ class AuthController {
|
||||
req.body.email = req.body.email?.toLowerCase();
|
||||
}
|
||||
await registrationBodyValidation.validateAsync(req.body);
|
||||
const { user, token } = await this.userService.registerUser(req.body, req.file);
|
||||
const { user, token, refreshToken } = await this.userService.registerUser(req.body, req.file);
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
msg: "User registered successfully",
|
||||
data: { user, token },
|
||||
data: { user, token, refreshToken },
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -53,7 +53,7 @@ class AuthController {
|
||||
req.body.email = req.body.email?.toLowerCase();
|
||||
}
|
||||
await loginValidation.validateAsync(req.body);
|
||||
const { user, token } = await this.userService.loginUser(req.body.email, req.body.password);
|
||||
const { user, token, refreshToken } = await this.userService.loginUser(req.body.email, req.body.password);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -61,6 +61,7 @@ class AuthController {
|
||||
data: {
|
||||
user,
|
||||
token,
|
||||
refreshToken,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -128,11 +129,35 @@ class AuthController {
|
||||
resetPassword = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await newPasswordValidation.validateAsync(req.body);
|
||||
const { user, token } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken);
|
||||
const { user, token, refreshToken } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken);
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Password has been reset successfully",
|
||||
data: { user, token },
|
||||
data: { user, token, refreshToken },
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
refreshAuthToken = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authToken = req.headers.authorization?.split(" ")[1];
|
||||
const refreshToken = req.headers["x-refresh-token"] as string;
|
||||
|
||||
if (!authToken || !refreshToken) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
msg: "Auth token and refresh token are required",
|
||||
});
|
||||
}
|
||||
|
||||
const { user, token, refreshToken: newRefreshToken } = await this.userService.refreshAuthToken(authToken, refreshToken);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Token refreshed successfully",
|
||||
data: { user, token, refreshToken: newRefreshToken },
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -41,7 +41,9 @@ const verifyJWT = (req, res, next) => {
|
||||
if (err) {
|
||||
const errorMessage = err.name === "TokenExpiredError" ? stringService.expiredAuthToken : stringService.invalidAuthToken;
|
||||
err.details = { msg: errorMessage };
|
||||
err.status = 401;
|
||||
// Return 403 for expired tokens to trigger client refresh flow
|
||||
// Return 401 for invalid tokens to trigger logout
|
||||
err.status = err.name === "TokenExpiredError" ? 403 : 401;
|
||||
err.service = SERVICE_NAME;
|
||||
err.method = "verifyJWT";
|
||||
next(err);
|
||||
|
||||
@@ -15,6 +15,7 @@ class AuthRoutes {
|
||||
initRoutes() {
|
||||
this.router.post("/register", upload.single("profileImage"), this.authController.registerUser);
|
||||
this.router.post("/login", this.authController.loginUser);
|
||||
this.router.post("/refresh", this.authController.refreshAuthToken);
|
||||
|
||||
this.router.post("/recovery/request", this.authController.requestRecovery);
|
||||
this.router.post("/recovery/validate", this.authController.validateRecovery);
|
||||
|
||||
@@ -62,6 +62,17 @@ class UserService {
|
||||
return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL });
|
||||
};
|
||||
|
||||
issueRefreshToken = (payload: any, appSettings: any) => {
|
||||
const refreshTokenTTL = appSettings?.refreshTokenTTL ?? "7d";
|
||||
const refreshTokenSecret = appSettings?.refreshTokenSecret ?? appSettings?.jwtSecret;
|
||||
const payloadData = { ...payload };
|
||||
// Remove sensitive data from refresh token payload
|
||||
delete payloadData.password;
|
||||
delete payloadData.profileImage;
|
||||
delete payloadData.avatarImage;
|
||||
return this.jwt.sign(payloadData, refreshTokenSecret, { expiresIn: refreshTokenTTL });
|
||||
};
|
||||
|
||||
registerUser = async (user: any, file: any) => {
|
||||
// Create a new user
|
||||
// If superAdmin exists, a token should be attached to all further register requests
|
||||
@@ -92,6 +103,7 @@ class UserService {
|
||||
const appSettings = await this.settingsService.getSettings();
|
||||
|
||||
const token = this.issueToken(userForToken, appSettings);
|
||||
const refreshToken = this.issueRefreshToken(userForToken, appSettings);
|
||||
|
||||
try {
|
||||
const html = await this.emailService.buildEmail("welcomeEmailTemplate", {
|
||||
@@ -114,7 +126,7 @@ class UserService {
|
||||
});
|
||||
}
|
||||
|
||||
return { user: newUser, token };
|
||||
return { user: newUser, token, refreshToken };
|
||||
};
|
||||
|
||||
loginUser = async (email: string, password: string) => {
|
||||
@@ -134,9 +146,10 @@ class UserService {
|
||||
// Happy path, return token
|
||||
const appSettings = await this.settingsService.getSettings();
|
||||
const token = this.issueToken(userWithoutPassword, appSettings);
|
||||
const refreshToken = this.issueRefreshToken(userWithoutPassword, appSettings);
|
||||
// reset avatar image
|
||||
userWithoutPassword.avatarImage = user.avatarImage;
|
||||
return { user: userWithoutPassword, token };
|
||||
return { user: userWithoutPassword, token, refreshToken };
|
||||
};
|
||||
|
||||
editUser = async (updates: any, file: any, currentUser: any) => {
|
||||
@@ -191,7 +204,39 @@ class UserService {
|
||||
const user = await this.db.recoveryModule.resetPassword(password, recoveryToken);
|
||||
const appSettings = await this.settingsService.getSettings();
|
||||
const token = this.issueToken(user._doc, appSettings);
|
||||
return { user, token };
|
||||
const refreshToken = this.issueRefreshToken(user._doc, appSettings);
|
||||
return { user, token, refreshToken };
|
||||
};
|
||||
|
||||
refreshAuthToken = async (oldAuthToken: string, refreshTokenFromHeader: string) => {
|
||||
const appSettings = await this.settingsService.getSettings();
|
||||
const refreshTokenSecret = appSettings?.refreshTokenSecret ?? appSettings?.jwtSecret;
|
||||
const jwtSecret = appSettings?.jwtSecret;
|
||||
|
||||
// Verify refresh token
|
||||
try {
|
||||
this.jwt.verify(refreshTokenFromHeader, refreshTokenSecret);
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.name === "TokenExpiredError" ? "Refresh token has expired" : "Invalid refresh token";
|
||||
throw this.errorService.createAuthenticationError(errorMessage);
|
||||
}
|
||||
|
||||
// Refresh token is valid, get payload from old auth token (ignoring expiration)
|
||||
let payloadData: any;
|
||||
try {
|
||||
payloadData = this.jwt.verify(oldAuthToken, jwtSecret, { ignoreExpiration: true });
|
||||
} catch (err: any) {
|
||||
throw this.errorService.createAuthenticationError("Invalid auth token");
|
||||
}
|
||||
|
||||
// Remove old token metadata
|
||||
delete payloadData.iat;
|
||||
delete payloadData.exp;
|
||||
|
||||
// Issue new access token
|
||||
const newAuthToken = this.issueToken(payloadData, appSettings);
|
||||
|
||||
return { user: payloadData, token: newAuthToken, refreshToken: refreshTokenFromHeader };
|
||||
};
|
||||
|
||||
deleteUser = async (user: any) => {
|
||||
|
||||
@@ -3,6 +3,8 @@ const SERVICE_NAME = "SettingsService";
|
||||
const envConfig = {
|
||||
jwtSecret: process.env.JWT_SECRET,
|
||||
jwtTTL: process.env.TOKEN_TTL,
|
||||
refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET || process.env.JWT_SECRET,
|
||||
refreshTokenTTL: process.env.REFRESH_TOKEN_TTL || "7d",
|
||||
systemEmailHost: process.env.SYSTEM_EMAIL_HOST,
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
logLevel: process.env.LOG_LEVEL,
|
||||
|
||||
Reference in new issue
Block a user