diff --git a/packages/app/src/App.jsx b/packages/app/src/App.jsx index b3a44a6e..c0352b19 100644 --- a/packages/app/src/App.jsx +++ b/packages/app/src/App.jsx @@ -101,15 +101,15 @@ class App extends React.Component { app.setLocation("/") } }, - "destroyed_session": async () => { + "session.destroyed": async () => { await this.flushState() - app.eventBus.emit("forceToLogin") + app.eventBus.emit("app.forceToLogin") }, - "forceToLogin": () => { - window.app.setLocation("/login") - app.eventBus.emit("app.createLogin") + "session.regenerated": async () => { + //await this.flushState() + //await this.initialization() }, - "invalid_session": async (error) => { + "session.invalid": async (error) => { const token = await Session.token if (!this.state.session && !token) { @@ -119,7 +119,7 @@ class App extends React.Component { await this.sessionController.forgetLocalSession() await this.flushState() - app.eventBus.emit("forceToLogin") + app.eventBus.emit("app.forceToLogin") antd.notification.open({ message: @@ -131,6 +131,10 @@ class App extends React.Component { icon: , }) }, + "app.forceToLogin": () => { + window.app.setLocation("/login") + app.eventBus.emit("app.createLogin") + }, "websocket_connected": () => { if (this.wsReconnecting) { this.wsReconnectingTry = 0 @@ -383,7 +387,7 @@ class App extends React.Component { const token = await Session.token if (!token || token == null) { - //window.app.eventBus.emit("no_session") + app.eventBus.emit("app.forceToLogin") return false } diff --git a/packages/app/src/cores/api/index.js b/packages/app/src/cores/api/index.js index 33f0a00d..71d37355 100644 --- a/packages/app/src/cores/api/index.js +++ b/packages/app/src/cores/api/index.js @@ -9,6 +9,9 @@ export default class ApiCore extends Core { this.namespaces = Object() + this.onExpiredExceptionEvent = false + this.excludedExpiredExceptionURL = ["/regenerate_session_token"] + this.ctx.registerPublicMethod("api", this) } @@ -36,6 +39,52 @@ export default class ApiCore extends Core { return this.namespaces[namespace].endpoints } + handleBeforeRequest = async (request) => { + if (this.onExpiredExceptionEvent) { + if (this.excludedExpiredExceptionURL.includes(request.url)) return + + await new Promise((resolve) => { + app.eventBus.once("session.regenerated", () => { + console.log(`Session has been regenerated, retrying request`) + resolve() + }) + }) + } + } + + handleRegenerationEvent = async (refreshToken, makeRequest) => { + window.app.eventBus.emit("session.expiredExceptionEvent", refreshToken) + + this.onExpiredExceptionEvent = true + + const expiredToken = await Session.token + + // exclude regeneration endpoint + + // send request to regenerate token + const response = await this.request("main", "post", "regenerateSessionToken", { + expiredToken: expiredToken, + refreshToken, + }).catch((error) => { + console.error(`Failed to regenerate token: ${error.message}`) + return false + }) + + if (!response) { + return window.app.eventBus.emit("session.invalid", "Failed to regenerate token") + } + + // set new token + Session.token = response.token + + //this.namespaces["main"].internalAbortController.abort() + + this.onExpiredExceptionEvent = false + + // emit event + window.app.eventBus.emit("session.regenerated") + } + connectBridge = (key, params) => { this.namespaces[key] = this.createBridge(params) } @@ -55,17 +104,21 @@ export default class ApiCore extends Core { return obj } - const handleResponse = async (data) => { - // handle token regeneration - if (data.headers?.regenerated_token) { - Session.token = data.headers.regenerated_token - console.debug("[REGENERATION] New token generated") - } - + const handleResponse = async (data, makeRequest) => { // handle 401 responses if (data instanceof Error) { if (data.response.status === 401) { - window.app.eventBus.emit("invalid_session") + // check if the server issue a refresh token on data + if (data.response.data.refreshToken) { + // handle regeneration event + await this.handleRegenerationEvent(data.response.data.refreshToken, makeRequest) + return await makeRequest() + } else { + return window.app.eventBus.emit("session.invalid", "Session expired, but the server did not issue a refresh token") + } + } + if (data.response.status === 403) { + return window.app.eventBus.emit("session.invalid", "Session not valid or not existent") } } } @@ -78,6 +131,7 @@ export default class ApiCore extends Core { wsOptions: { autoConnect: false, }, + onBeforeRequest: this.handleBeforeRequest, onRequest: getSessionContext, onResponse: handleResponse, ...params, diff --git a/packages/app/src/models/session/index.js b/packages/app/src/models/session/index.js index eea8692d..aaab719c 100644 --- a/packages/app/src/models/session/index.js +++ b/packages/app/src/models/session/index.js @@ -103,7 +103,7 @@ export default class Session { const result = await Session.bridge.delete.sessions({ user_id: session.user_id }) this.forgetLocalSession() - window.app.eventBus.emit("destroyed_session") + window.app.eventBus.emit("session.destroyed") return result } @@ -118,7 +118,7 @@ export default class Session { const result = await Session.bridge.delete.session({ user_id: session.user_id, token: token }) this.forgetLocalSession() - window.app.eventBus.emit("destroyed_session") + window.app.eventBus.emit("session.destroyed") return result } diff --git a/packages/server/src/api.js b/packages/server/src/api.js index 311ec53f..cf733214 100644 --- a/packages/server/src/api.js +++ b/packages/server/src/api.js @@ -51,7 +51,8 @@ export default class Server { jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: this.server.oskid, algorithms: ["sha1", "RS256", "HS256"], - expiresIn: this.env.signLifetime ?? "1h", + expiresIn: this.env.signLifetime ?? "10s", + enforceRegenerationTokenExpiration: false, } } diff --git a/packages/server/src/controllers/CommentsController/index.js b/packages/server/src/controllers/CommentsController/index.js new file mode 100644 index 00000000..b4d1c805 --- /dev/null +++ b/packages/server/src/controllers/CommentsController/index.js @@ -0,0 +1,23 @@ +import { Controller } from "linebridge/dist/server" +import { User, Post, Comment } from "../../models" +import { Schematized } from "../../lib" + +export default class CommentsController extends Controller { + static refName = "CommentsController" + + get = { + + } + + post = { + + } + + put = { + + } + + delete = { + + } +} \ No newline at end of file diff --git a/packages/server/src/controllers/SessionController/index.js b/packages/server/src/controllers/SessionController/index.js index cba56050..90ea1f43 100644 --- a/packages/server/src/controllers/SessionController/index.js +++ b/packages/server/src/controllers/SessionController/index.js @@ -1,7 +1,9 @@ import { Controller } from "linebridge/dist/server" -import { Session } from "../../models" import jwt from "jsonwebtoken" +import { Session } from "../../models" +import { Token } from "../../lib" + export default class SessionController extends Controller { static refName = "SessionController" @@ -66,6 +68,22 @@ export default class SessionController extends Controller { return res.json(result) }, }, + "/regenerate_session_token": { + middlewares: ["useJwtStrategy"], + fn: async (req, res) => { + const { expiredToken, refreshToken } = req.body + + const token = await Token.regenerateSession(expiredToken, refreshToken).catch((error) => { + res.status(400).json({ error: error.message }) + + return null + }) + + if (!token) return + + return res.json({ token }) + }, + } } delete = { diff --git a/packages/server/src/lib/token/index.js b/packages/server/src/lib/token/index.js index 91e7033f..875ea028 100644 --- a/packages/server/src/lib/token/index.js +++ b/packages/server/src/lib/token/index.js @@ -1,32 +1,150 @@ import jwt from "jsonwebtoken" import { nanoid } from "nanoid" -import { Session, User } from "../../models" +import { Session, RegenerationToken } from "../../models" + +export async function regenerateSession(expiredToken, refreshToken) { + // search for a regeneration token with the expired token (Should exist only one) + const regenerationToken = await RegenerationToken.findOne({ refreshToken: refreshToken }) + + if (!regenerationToken) { + throw new Error("Cannot find regeneration token") + } + + // check if the regeneration token is valid and not expired + let decodedRefreshToken = null + let decodedExpiredToken = null + + try { + decodedRefreshToken = jwt.decode(refreshToken) + decodedExpiredToken = jwt.decode(expiredToken) + } catch (error) { + console.error(error) + // TODO: Storage this incident + } + + if (!decodedRefreshToken) { + throw new Error("Cannot decode refresh token") + } + + if (!decodedExpiredToken) { + throw new Error("Cannot decode expired token") + } + + // is not needed to verify the expired token, because it suppossed to be expired + + // verify refresh token + await jwt.verify(refreshToken, global.jwtStrategy.secretOrKey, async (err) => { + // check if is expired + if (err) { + if (err.message === "jwt expired") { + // check if server has enabled the enforcement of regeneration token expiration + if (global.jwtStrategy.enforceRegenerationTokenExpiration) { + // delete the regeneration token + await RegenerationToken.deleteOne({ refreshToken: refreshToken }) + + throw new Error("Regeneration token expired and cannot be regenerated due server has enabled enforcement security policy") + } + } + } + + // check if the regeneration token is associated with the expired token + if (decodedRefreshToken.expiredToken !== expiredToken) { + throw new Error("Regeneration token is not associated with the expired token") + } + }) + + // find the session associated with the expired token + const session = await Session.findOne({ token: expiredToken }) + + if (!session) { + throw new Error("Cannot find session associated with the expired token") + } + + // generate a new token + const newToken = await createNewAuthToken(decodedExpiredToken, { + updateSession: session._id, + }) + + // delete the regeneration token + await RegenerationToken.deleteOne({ refreshToken: refreshToken }) + + return newToken +} + +export async function getRegenerationToken(expiredToken) { + const regenerationToken = await RegenerationToken.findOne({ expiredToken }).catch((error) => false) + + return regenerationToken ?? false +} + +export async function createNewRegenerationToken(expiredToken) { + // check if token is only expired, if is corrupted, reject + let decoded = null + + try { + decoded = jwt.decode(expiredToken) + } catch (error) { + console.error(error) + } + + if (!decoded) { + return false + } + + // check if token exists on a session + const sessions = await Session.find({ user_id: decoded.user_id }) + const currentSession = sessions.find((session) => session.token === expiredToken) + + if (!currentSession) { + throw new Error("This token is not associated with any session") + } + + // create a new refresh token and sign it with maximum expiration time of 1 day + const refreshToken = jwt.sign( + { + expiredToken + }, + global.jwtStrategy.secretOrKey, + { + expiresIn: "1d" + } + ) + + // create a new regeneration token and save it + const regenerationToken = new RegenerationToken({ + expiredToken, + refreshToken, + }) + + await regenerationToken.save() + + // return the regeneration token + return regenerationToken +} export async function createNewAuthToken(user, options = {}) { const payload = { - user_id: user._id, + user_id: user._id ?? user.user_id, username: user.username, email: user.email, - refreshToken: nanoid(), signLocation: global.signLocation, } - await User.findByIdAndUpdate(user._id, { refreshToken: payload.refreshToken }) - - return await signNew(payload, options) + return await signNewAuthToken(payload, options) } -export async function signNew(payload, options = {}) { +export async function signNewAuthToken(payload, options = {}) { if (options.updateSession) { - const sessionData = await Session.findById(options.updateSession) + const sessionData = await Session.findOne({ _id: options.updateSession }) + payload.session_uuid = sessionData.session_uuid } else { payload.session_uuid = nanoid() } - const token = jwt.sign(payload, options.secretOrKey, { - expiresIn: options.expiresIn ?? "1h", - algorithm: options.algorithm ?? "HS256" + const token = jwt.sign(payload, global.jwtStrategy.secretOrKey, { + expiresIn: global.jwtStrategy.expiresIn ?? "1h", + algorithm: global.jwtStrategy.algorithm ?? "HS256" }) const session = { @@ -47,7 +165,7 @@ export async function signNew(payload, options = {}) { } else { let newSession = new Session(session) - newSession.save() + await newSession.save() } return token diff --git a/packages/server/src/middlewares/withAuthentication/index.js b/packages/server/src/middlewares/withAuthentication/index.js index 4c1f7d52..b3dcf481 100644 --- a/packages/server/src/middlewares/withAuthentication/index.js +++ b/packages/server/src/middlewares/withAuthentication/index.js @@ -4,7 +4,7 @@ import jwt from "jsonwebtoken" export default (req, res, next) => { function reject(description) { - return res.status(401).json({ error: `${description ?? "Invalid session"}` }) + return res.status(403).json({ error: `${description ?? "Invalid session"}` }) } const authHeader = req.headers?.authorization?.split(" ") @@ -37,17 +37,37 @@ export default (req, res, next) => { return res.status(404).json({ error: "No user data found" }) } + // if cannot verify token, start regeneration process if (err) { - if (decoded.refreshToken === userData.refreshToken) { - const regeneratedToken = await Token.createNewAuthToken(userData, { - ...global.jwtStrategy, - updateSession: currentSession._id, - }) - - res.setHeader("regenerated_token", regeneratedToken) - } else { - return reject("Token expired, cannot refresh token either") + // first check if token is only expired, if is corrupted, reject + if (err.name !== "TokenExpiredError") { + return reject("Invalid token, cannot regenerate") } + + let regenerationToken = null + + // check if this expired token has a regeneration token associated + const associatedRegenerationToken = await Token.getRegenerationToken(token) + + if (associatedRegenerationToken) { + regenerationToken = associatedRegenerationToken.refreshToken + } else { + // create a new regeneration token with the expired token + regenerationToken = await Token.createNewRegenerationToken(token).catch((error) => { + // in case of error, reject + reject(error.message) + + return null + }) + } + + if (!regenerationToken) return + + // now send the regeneration token to the client (start Expired Exception Event [EEE]) + return res.status(401).json({ + error: "Token expired", + refreshToken: regenerationToken.refreshToken, + }) } req.user = userData diff --git a/packages/server/src/models/index.js b/packages/server/src/models/index.js index c23898fa..acd7c0e6 100644 --- a/packages/server/src/models/index.js +++ b/packages/server/src/models/index.js @@ -16,10 +16,13 @@ const schemas = getSchemas() // server export const Config = mongoose.model("Config", schemas.Config, "config") +// sessions +export const Session = mongoose.model("Session", schemas.Session, "sessions") +export const RegenerationToken = mongoose.model("RegenerationToken", schemas.RegenerationToken, "regenerationTokens") + // users export const User = mongoose.model("User", schemas.User, "accounts") export const UserFollow = mongoose.model("UserFollow", schemas.UserFollow, "follows") -export const Session = mongoose.model("Session", schemas.Session, "sessions") export const Role = mongoose.model("Role", schemas.Role, "roles") export const Badge = mongoose.model("Badge", schemas.Badge, "badges") diff --git a/packages/server/src/schemas/index.js b/packages/server/src/schemas/index.js index b49b5ec7..6929bb1a 100644 --- a/packages/server/src/schemas/index.js +++ b/packages/server/src/schemas/index.js @@ -1,6 +1,8 @@ +export { default as Session } from "./session" +export { default as RegenerationToken } from "./regenerationToken" + export { default as User } from "./user" export { default as Role } from "./role" -export { default as Session } from "./session" export { default as Config } from "./config" export { default as Post } from "./post" export { default as Comment } from "./comment" diff --git a/packages/server/src/schemas/regenerationToken/index.js b/packages/server/src/schemas/regenerationToken/index.js new file mode 100644 index 00000000..3559d7ed --- /dev/null +++ b/packages/server/src/schemas/regenerationToken/index.js @@ -0,0 +1,10 @@ +export default { + expiredToken: { + type: String, + required: true, + }, + refreshToken: { + type: String, + required: true, + } +} \ No newline at end of file diff --git a/packages/server/src/schemas/user/index.js b/packages/server/src/schemas/user/index.js index fdee9af6..606a5ea7 100644 --- a/packages/server/src/schemas/user/index.js +++ b/packages/server/src/schemas/user/index.js @@ -1,5 +1,4 @@ export default { - refreshToken: { type: String, select: false }, username: { type: String, required: true }, password: { type: String, required: true, select: false }, cover: { type: String },