Implement new JWT auth

This commit is contained in:
advplyr 2025-06-29 17:22:58 -05:00
parent e384863148
commit 4f5123e842
21 changed files with 739 additions and 56 deletions

View file

@ -1,5 +1,6 @@
const axios = require('axios')
const passport = require('passport')
const { Op } = require('sequelize')
const { Request, Response, NextFunction } = require('express')
const bcrypt = require('./libs/bcryptjs')
const jwt = require('./libs/jsonwebtoken')
@ -21,6 +22,9 @@ class Auth {
this.openIdAuthSession = new Map()
const escapedRouterBasePath = escapeRegExp(global.RouterBasePath)
this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)]
this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days
this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours
}
/**
@ -406,6 +410,22 @@ class Auth {
res.cookie('auth_method', authMethod, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true })
}
/**
* Sets the refresh token cookie
* @param {Request} req
* @param {Response} res
* @param {string} refreshToken
*/
setRefreshTokenCookie(req, res, refreshToken) {
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: req.secure || req.get('x-forwarded-proto') === 'https',
sameSite: 'lax',
maxAge: this.RefreshTokenExpiry * 1000,
path: '/'
})
}
/**
* Informs the client in the right mode about a successfull login and the token
* (clients choise is restored from cookies).
@ -444,17 +464,77 @@ class Auth {
// return the user login response json if the login was successfull
const userResponse = await this.getUserLoginResponsePayload(req.user)
// Experimental Next.js client uses bearer token in cookies
res.cookie('auth_token', userResponse.user.token, {
httpOnly: true,
secure: req.secure || req.get('x-forwarded-proto') === 'https',
sameSite: 'strict',
maxAge: 1000 * 60 * 60 * 24 * 7 // 7 days
})
this.setRefreshTokenCookie(req, res, req.user.refreshToken)
res.json(userResponse)
})
// Refresh token route
router.post('/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refresh_token
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token provided' })
}
try {
// Verify the refresh token
const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret)
if (decoded.type !== 'refresh') {
return res.status(401).json({ error: 'Invalid token type' })
}
const session = await Database.sessionModel.findOne({
where: { refreshToken: refreshToken }
})
if (!session) {
return res.status(401).json({ error: 'Invalid refresh token' })
}
// Check if session is expired in database
if (session.expiresAt < new Date()) {
Logger.info(`[Auth] Session expired in database, cleaning up`)
await session.destroy()
return res.status(401).json({ error: 'Refresh token expired' })
}
const user = await Database.userModel.getUserById(decoded.userId)
if (!user?.isActive) {
return res.status(401).json({ error: 'User not found or inactive' })
}
const newAccessToken = await this.rotateTokensForSession(session, user, req, res)
user.accessToken = newAccessToken
const userResponse = await this.getUserLoginResponsePayload(user)
res.json(userResponse)
} catch (error) {
if (error.name === 'TokenExpiredError') {
Logger.info(`[Auth] Refresh token expired, cleaning up session`)
// Clean up the expired session from database
try {
await Database.sessionModel.destroy({
where: { refreshToken: refreshToken }
})
Logger.info(`[Auth] Expired session cleaned up`)
} catch (cleanupError) {
Logger.error(`[Auth] Error cleaning up expired session: ${cleanupError.message}`)
}
return res.status(401).json({ error: 'Refresh token expired' })
} else if (error.name === 'JsonWebTokenError') {
Logger.error(`[Auth] Invalid refresh token format: ${error.message}`)
return res.status(401).json({ error: 'Invalid refresh token' })
} else {
Logger.error(`[Auth] Refresh token error: ${error.message}`)
return res.status(401).json({ error: 'Invalid refresh token' })
}
}
})
// openid strategy login route (this redirects to the configured openid login provider)
router.get('/auth/openid', (req, res, next) => {
// Get the OIDC client from the strategy
@ -719,7 +799,24 @@ class Auth {
})
// Logout route
router.post('/logout', (req, res) => {
router.post('/logout', async (req, res) => {
const refreshToken = req.cookies.refresh_token
// Clear refresh token cookie
res.clearCookie('refresh_token', {
path: '/'
})
// Invalidate the session in database using refresh token
if (refreshToken) {
try {
await Database.sessionModel.destroy({
where: { refreshToken }
})
} catch (error) {
Logger.error(`[Auth] Error destroying session: ${error.message}`)
}
}
// TODO: invalidate possible JWTs
req.logout((err) => {
if (err) {
@ -728,7 +825,6 @@ class Auth {
const authMethod = req.cookies.auth_method
res.clearCookie('auth_method')
res.clearCookie('auth_token')
let logoutUrl = null
@ -776,22 +872,18 @@ class Auth {
/**
* middleware to use in express to only allow authenticated users.
*
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
isAuthenticated(req, res, next) {
// check if session cookie says that we are authenticated
if (req.isAuthenticated()) {
next()
} else {
// try JWT to authenticate
passport.authenticate('jwt')(req, res, next)
}
return passport.authenticate('jwt', { session: false })(req, res, next)
}
/**
* Function to generate a jwt token for a given user
* TODO: Old method with no expiration
*
* @param {{ id:string, username:string }} user
* @returns {string} token
@ -800,6 +892,132 @@ class Auth {
return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret)
}
/**
* Generate access token for a given user
*
* @param {{ id:string, username:string }} user
* @returns {Promise<string>}
*/
generateTempAccessToken(user) {
return new Promise((resolve) => {
jwt.sign({ userId: user.id, username: user.username, type: 'access' }, global.ServerSettings.tokenSecret, { expiresIn: this.AccessTokenExpiry }, (err, token) => {
if (err) {
Logger.error(`[Auth] Error generating access token for user ${user.id}: ${err}`)
resolve(null)
} else {
resolve(token)
}
})
})
}
/**
* Generate refresh token for a given user
*
* @param {{ id:string, username:string }} user
* @returns {Promise<string>}
*/
generateRefreshToken(user) {
return new Promise((resolve) => {
jwt.sign({ userId: user.id, username: user.username, type: 'refresh' }, global.ServerSettings.tokenSecret, { expiresIn: this.RefreshTokenExpiry }, (err, token) => {
if (err) {
Logger.error(`[Auth] Error generating refresh token for user ${user.id}: ${err}`)
resolve(null)
} else {
resolve(token)
}
})
})
}
/**
* Create tokens and session for a given user
*
* @param {{ id:string, username:string }} user
* @param {Request} req
* @returns {Promise<{ accessToken:string, refreshToken:string, session:import('./models/Session') }>}
*/
async createTokensAndSession(user, req) {
const ipAddress = requestIp.getClientIp(req)
const userAgent = req.headers['user-agent']
const [accessToken, refreshToken] = await Promise.all([this.generateTempAccessToken(user), this.generateRefreshToken(user)])
// Calculate expiration time for the refresh token
const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt)
user.accessToken = accessToken
// Store refresh token on user object for cookie setting
user.refreshToken = refreshToken
return { accessToken, refreshToken, session }
}
/**
* Rotate tokens for a given session
*
* @param {import('./models/Session')} session
* @param {import('./models/User')} user
* @param {Request} req
* @param {Response} res
* @returns {Promise<string>} newAccessToken
*/
async rotateTokensForSession(session, user, req, res) {
// Generate new tokens
const [newAccessToken, newRefreshToken] = await Promise.all([this.generateTempAccessToken(user), this.generateRefreshToken(user)])
// Calculate new expiration time
const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
// Update the session with the new refresh token and expiration
session.refreshToken = newRefreshToken
session.expiresAt = newExpiresAt
await session.save()
// Set new refresh token cookie
this.setRefreshTokenCookie(req, res, newRefreshToken)
return newAccessToken
}
/**
* Invalidate all JWT sessions for a given user
* If user is current user and refresh token is valid, rotate tokens for the current session
*
* @param {Request} req
* @param {Response} res
* @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
*/
async invalidateJwtSessionsForUser(user, req, res) {
const currentRefreshToken = req.cookies.refresh_token
if (req.user.id === user.id && currentRefreshToken) {
// Current user is the same as the user to invalidate sessions for
// So rotate token for current session
const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })
if (currentSession) {
const newAccessToken = await this.rotateTokensForSession(currentSession, user, req, res)
// Invalidate all sessions for the user except the current one
await Database.sessionModel.destroy({
where: {
id: {
[Op.ne]: currentSession.id
},
userId: user.id
}
})
return newAccessToken
} else {
Logger.error(`[Auth] No session found to rotate tokens for refresh token ${currentRefreshToken}`)
}
}
// Current user is not the same as the user to invalidate sessions for (or no refresh token)
// So invalidate all sessions for the user
await Database.sessionModel.destroy({ where: { userId: user.id } })
return null
}
/**
* Function to validate a jwt token for a given user
*
@ -888,6 +1106,10 @@ class Auth {
}
// approve login
Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
// Create tokens and session, updates user.accessToken and user.refreshToken
await this.createTokensAndSession(user, req)
done(null, user)
return
} else if (!user.pash) {
@ -901,6 +1123,10 @@ class Auth {
if (compare) {
// approve login
Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
// Create tokens and session, updates user.accessToken and user.refreshToken
await this.createTokensAndSession(user, req)
done(null, user)
return
}

View file

@ -42,6 +42,16 @@ class Database {
return this.models.user
}
/** @type {typeof import('./models/Session')} */
get sessionModel() {
return this.models.session
}
/** @type {typeof import('./models/ApiToken')} */
get apiTokenModel() {
return this.models.apiToken
}
/** @type {typeof import('./models/Library')} */
get libraryModel() {
return this.models.library
@ -311,6 +321,8 @@ class Database {
buildModels(force = false) {
require('./models/User').init(this.sequelize)
require('./models/Session').init(this.sequelize)
require('./models/ApiToken').init(this.sequelize)
require('./models/Library').init(this.sequelize)
require('./models/LibraryFolder').init(this.sequelize)
require('./models/Book').init(this.sequelize)
@ -656,6 +668,8 @@ class Database {
* Series should have atleast one Book
* Book and Podcast must have an associated LibraryItem (and vice versa)
* Remove playback sessions that are 3 seconds or less
* Remove duplicate mediaProgresses
* Remove expired auth sessions
*/
async cleanDatabase() {
// Remove invalid Podcast records
@ -785,6 +799,23 @@ WHERE EXISTS (
where: { id: duplicateMediaProgress.id }
})
}
// Remove expired Session records
await this.cleanupExpiredSessions()
}
/**
* Clean up expired sessions from the database
*/
async cleanupExpiredSessions() {
try {
const deletedCount = await this.sessionModel.cleanupExpiredSessions()
if (deletedCount > 0) {
Logger.info(`[Database] Cleaned up ${deletedCount} expired sessions`)
}
} catch (error) {
Logger.error(`[Database] Error cleaning up expired sessions: ${error.message}`)
}
}
async createTextSearchQuery(query) {

View file

@ -237,6 +237,7 @@ class UserController {
let hasUpdates = false
let shouldUpdateToken = false
let shouldInvalidateJwtSessions = false
// When changing username create a new API token
if (updatePayload.username && updatePayload.username !== user.username) {
const usernameExists = await Database.userModel.checkUserExistsWithUsername(updatePayload.username)
@ -245,6 +246,7 @@ class UserController {
}
user.username = updatePayload.username
shouldUpdateToken = true
shouldInvalidateJwtSessions = true
hasUpdates = true
}
@ -328,6 +330,18 @@ class UserController {
user.token = await this.auth.generateAccessToken(user)
Logger.info(`[UserController] User ${user.username} has generated a new api token`)
}
// Handle JWT session invalidation for username changes
if (shouldInvalidateJwtSessions) {
const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res)
if (newAccessToken) {
user.accessToken = newAccessToken
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username} and rotated tokens for current session`)
} else {
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`)
}
}
await user.save()
SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toOldJSONForBrowser())
}

View file

@ -31,10 +31,11 @@ class CronManager {
}
/**
* Initialize open session cleanup cron
* Initialize open session & auth session cleanup cron
* Runs every day at 00:30
* Closes open share sessions that have not been updated in 24 hours
* Closes open playback sessions that have not been updated in 36 hours
* Cleans up expired auth sessions
* TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner
*/
initOpenSessionCleanupCron() {
@ -42,6 +43,7 @@ class CronManager {
Logger.debug('[CronManager] Open session cleanup cron executing')
ShareManager.closeStaleOpenShareSessions()
await this.playbackSessionManager.closeStaleOpenSessions()
await Database.cleanupExpiredSessions()
})
}

View file

@ -0,0 +1,153 @@
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
const migrationVersion = '2.26.0'
const migrationName = `${migrationVersion}-create-sessions-table`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This upward migration creates a sessions table and apiTokens table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
// Upwards migration script
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
// Check if table exists
if (await queryInterface.tableExists('sessions')) {
logger.info(`${loggerPrefix} table "sessions" already exists`)
} else {
// Create table
logger.info(`${loggerPrefix} creating table "sessions"`)
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
await queryInterface.createTable('sessions', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
ipAddress: DataTypes.STRING,
userAgent: DataTypes.STRING,
refreshToken: {
type: DataTypes.STRING,
allowNull: false
},
expiresAt: {
type: DataTypes.DATE,
allowNull: false
},
createdAt: {
type: DataTypes.DATE,
allowNull: false
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false
},
userId: {
type: DataTypes.UUID,
references: {
model: {
tableName: 'users'
},
key: 'id'
},
allowNull: false,
onDelete: 'CASCADE'
}
})
logger.info(`${loggerPrefix} created table "sessions"`)
}
// Check if table exists
if (await queryInterface.tableExists('apiTokens')) {
logger.info(`${loggerPrefix} table "apiTokens" already exists`)
} else {
// Create table
logger.info(`${loggerPrefix} creating table "apiTokens"`)
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
await queryInterface.createTable('apiTokens', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
tokenHash: {
type: DataTypes.STRING,
allowNull: false
},
expiresAt: DataTypes.DATE,
lastUsedAt: DataTypes.DATE,
isActive: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
permissions: DataTypes.JSON,
createdAt: {
type: DataTypes.DATE,
allowNull: false
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false
},
userId: {
type: DataTypes.UUID,
references: {
model: {
tableName: 'users'
},
key: 'id'
},
allowNull: false,
onDelete: 'CASCADE'
}
})
logger.info(`${loggerPrefix} created table "apiTokens"`)
}
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This downward migration script removes the sessions table and apiTokens table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
// Downward migration script
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
// Check if table exists
if (await queryInterface.tableExists('sessions')) {
logger.info(`${loggerPrefix} dropping table "sessions"`)
// Drop table
await queryInterface.dropTable('sessions')
logger.info(`${loggerPrefix} dropped table "sessions"`)
} else {
logger.info(`${loggerPrefix} table "sessions" does not exist`)
}
if (await queryInterface.tableExists('apiTokens')) {
logger.info(`${loggerPrefix} dropping table "apiTokens"`)
await queryInterface.dropTable('apiTokens')
logger.info(`${loggerPrefix} dropped table "apiTokens"`)
} else {
logger.info(`${loggerPrefix} table "apiTokens" does not exist`)
}
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
module.exports = { up, down }

90
server/models/ApiToken.js Normal file
View file

@ -0,0 +1,90 @@
const { DataTypes, Model, Op } = require('sequelize')
class ApiToken extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.name
/** @type {string} */
this.tokenHash
/** @type {Date} */
this.expiresAt
/** @type {Date} */
this.lastUsedAt
/** @type {boolean} */
this.isActive
/** @type {Object} */
this.permissions
/** @type {Date} */
this.createdAt
/** @type {UUIDV4} */
this.userId
// Expanded properties
/** @type {import('./User').User} */
this.user
}
/**
* Clean up expired api tokens from the database
* @returns {Promise<number>} Number of api tokens deleted
*/
static async cleanupExpiredApiTokens() {
const deletedCount = await ApiToken.destroy({
where: {
expiresAt: {
[Op.lt]: new Date()
}
}
})
return deletedCount
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
tokenHash: {
type: DataTypes.STRING,
allowNull: false
},
expiresAt: DataTypes.DATE,
lastUsedAt: DataTypes.DATE,
isActive: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
permissions: DataTypes.JSON
},
{
sequelize,
modelName: 'apiToken'
}
)
const { user } = sequelize.models
user.hasMany(ApiToken, {
onDelete: 'CASCADE',
foreignKey: {
allowNull: false
}
})
ApiToken.belongsTo(user)
}
}
module.exports = ApiToken

88
server/models/Session.js Normal file
View file

@ -0,0 +1,88 @@
const { DataTypes, Model, Op } = require('sequelize')
class Session extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.ipAddress
/** @type {string} */
this.userAgent
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
/** @type {UUIDV4} */
this.userId
/** @type {Date} */
this.expiresAt
// Expanded properties
/** @type {import('./User').User} */
this.user
}
static async createSession(userId, ipAddress, userAgent, refreshToken, expiresAt) {
const session = await Session.create({ userId, ipAddress, userAgent, refreshToken, expiresAt })
return session
}
/**
* Clean up expired sessions from the database
* @returns {Promise<number>} Number of sessions deleted
*/
static async cleanupExpiredSessions() {
const deletedCount = await Session.destroy({
where: {
expiresAt: {
[Op.lt]: new Date()
}
}
})
return deletedCount
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
ipAddress: DataTypes.STRING,
userAgent: DataTypes.STRING,
refreshToken: {
type: DataTypes.STRING,
allowNull: false
},
expiresAt: {
type: DataTypes.DATE,
allowNull: false
}
},
{
sequelize,
modelName: 'session'
}
)
const { user } = sequelize.models
user.hasMany(Session, {
onDelete: 'CASCADE',
foreignKey: {
allowNull: false
}
})
Session.belongsTo(user)
}
}
module.exports = Session

View file

@ -112,6 +112,10 @@ class User extends Model {
this.updatedAt
/** @type {import('./MediaProgress')[]?} - Only included when extended */
this.mediaProgresses
// Temporary accessToken, not stored in database
/** @type {string} */
this.accessToken
}
// Excludes "root" since their can only be 1 root user
@ -520,7 +524,9 @@ class User extends Model {
username: this.username,
email: this.email,
type: this.type,
// TODO: Old non-expiring token
token: this.type === 'root' && hideRootToken ? '' : this.token,
accessToken: this.accessToken || null,
mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [],
seriesHideFromContinueListening: [...seriesHideFromContinueListening],
bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [],