WIP - first version of user sessions.
This commit is contained in:
parent
eaf808b190
commit
b847f85d91
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
user: require("./src/cache/user"),
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
module.exports = require("./src/security/sessions")
|
|
@ -0,0 +1,22 @@
|
|||
const { getDB } = require("../db")
|
||||
const { StaticDatabases } = require("../db/utils")
|
||||
const redis = require("../redis/authRedis")
|
||||
|
||||
const EXPIRY_SECONDS = 3600
|
||||
|
||||
|
||||
exports.getUser = async userId => {
|
||||
const client = await redis.getUserClient()
|
||||
// try cache
|
||||
let user = await client.get(userId)
|
||||
if (!user) {
|
||||
user = await getDB(StaticDatabases.GLOBAL.name).get(userId)
|
||||
client.store(userId, user, EXPIRY_SECONDS)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
exports.invalidateUser = async userId => {
|
||||
const client = await redis.getUserClient()
|
||||
await client.delete(userId)
|
||||
}
|
|
@ -4,6 +4,7 @@ const JwtStrategy = require("passport-jwt").Strategy
|
|||
const { StaticDatabases } = require("./db/utils")
|
||||
const { jwt, local, authenticated, google, auditLog } = require("./middleware")
|
||||
const { setDB, getDB } = require("./db")
|
||||
const userCache = require("./cache/user")
|
||||
|
||||
// Strategies
|
||||
passport.use(new LocalStrategy(local.options, local.authenticate))
|
||||
|
@ -47,6 +48,9 @@ module.exports = {
|
|||
jwt: require("jsonwebtoken"),
|
||||
auditLog,
|
||||
},
|
||||
cache: {
|
||||
user: userCache,
|
||||
},
|
||||
StaticDatabases,
|
||||
constants: require("./constants"),
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
const { Cookies } = require("../constants")
|
||||
const database = require("../db")
|
||||
const { getCookie, clearCookie } = require("../utils")
|
||||
const { StaticDatabases } = require("../db/utils")
|
||||
const { getUser } = require("../cache/user")
|
||||
const { getSession, updateSessionTTL } = require("../security/sessions")
|
||||
const env = require("../environment")
|
||||
|
||||
const PARAM_REGEX = /\/:(.*?)\//g
|
||||
|
@ -48,14 +48,26 @@ module.exports = (noAuthPatterns = [], opts) => {
|
|||
user = null,
|
||||
internal = false
|
||||
if (authCookie) {
|
||||
try {
|
||||
const db = database.getDB(StaticDatabases.GLOBAL.name)
|
||||
user = await db.get(authCookie.userId)
|
||||
delete user.password
|
||||
authenticated = true
|
||||
} catch (err) {
|
||||
// remove the cookie as the use does not exist anymore
|
||||
let error = null
|
||||
const sessionId = authCookie.sessionId, userId = authCookie.userId
|
||||
const session = await getSession(userId, sessionId)
|
||||
if (!session) {
|
||||
error = "No session found"
|
||||
} else {
|
||||
try {
|
||||
const user = await getUser(userId)
|
||||
delete user.password
|
||||
authenticated = true
|
||||
} catch (err) {
|
||||
error = err
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
// remove the cookie as the user does not exist anymore
|
||||
clearCookie(ctx, Cookies.Auth)
|
||||
} else {
|
||||
// make sure we denote that the session is still in use
|
||||
await updateSessionTTL(userId, sessionId)
|
||||
}
|
||||
}
|
||||
const apiKey = ctx.request.headers["x-budibase-api-key"]
|
||||
|
|
|
@ -7,6 +7,8 @@ const {
|
|||
generateGlobalUserID,
|
||||
ViewNames,
|
||||
} = require("../../db/utils")
|
||||
const { newid } = require("../../hashing")
|
||||
const { createASession } = require("../../security/sessions")
|
||||
|
||||
async function authenticate(token, tokenSecret, profile, done) {
|
||||
// Check the user exists in the instance DB by email
|
||||
|
@ -59,15 +61,15 @@ async function authenticate(token, tokenSecret, profile, done) {
|
|||
}
|
||||
|
||||
// authenticate
|
||||
const sessionId = newid()
|
||||
const payload = {
|
||||
userId: dbUser._id,
|
||||
builder: dbUser.builder,
|
||||
email: dbUser.email,
|
||||
sessionId,
|
||||
}
|
||||
await createASession(dbUser._id, sessionId, payload)
|
||||
dbUser.sessionId = sessionId
|
||||
|
||||
dbUser.token = jwt.sign(payload, env.JWT_SECRET, {
|
||||
expiresIn: "1 day",
|
||||
})
|
||||
dbUser.token = jwt.sign(payload, env.JWT_SECRET)
|
||||
|
||||
return done(null, dbUser)
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@ const { UserStatus } = require("../../constants")
|
|||
const { compare } = require("../../hashing")
|
||||
const env = require("../../environment")
|
||||
const { getGlobalUserByEmail } = require("../../utils")
|
||||
const { newid } = require("../../hashing")
|
||||
const { createASession } = require("../../security/sessions")
|
||||
|
||||
const INVALID_ERR = "Invalid Credentials"
|
||||
|
||||
|
@ -31,13 +33,15 @@ exports.authenticate = async function (email, password, done) {
|
|||
|
||||
// authenticate
|
||||
if (await compare(password, dbUser.password)) {
|
||||
const sessionId = newid()
|
||||
const payload = {
|
||||
userId: dbUser._id,
|
||||
sessionId,
|
||||
}
|
||||
await createASession(dbUser._id, sessionId, payload)
|
||||
dbUser.sessionId = sessionId
|
||||
|
||||
dbUser.token = jwt.sign(payload, env.JWT_SECRET, {
|
||||
expiresIn: "1 day",
|
||||
})
|
||||
dbUser.token = jwt.sign(payload, env.JWT_SECRET)
|
||||
// Remove users password in payload
|
||||
delete dbUser.password
|
||||
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
const { Client, utils } = require("./index")
|
||||
|
||||
let userClient, sessionClient
|
||||
|
||||
async function init() {
|
||||
userClient = await new Client(utils.Databases.USER_CACHE).init()
|
||||
sessionClient = await new Client(utils.Databases.SESSIONS).init()
|
||||
}
|
||||
|
||||
process.on("exit", async () => {
|
||||
if (userClient) await userClient.finish()
|
||||
if (sessionClient) await sessionClient.finish()
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
getUserClient: async () => {
|
||||
if (!userClient) {
|
||||
await init()
|
||||
}
|
||||
return userClient
|
||||
},
|
||||
getSessionClient: async () => {
|
||||
if (!sessionClient) {
|
||||
await init()
|
||||
}
|
||||
return sessionClient
|
||||
},
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
const env = require("../environment")
|
||||
// ioredis mock is all in memory
|
||||
const Redis = env.isTest() ? require("ioredis-mock") : require("ioredis")
|
||||
const { addDbPrefix, removeDbPrefix, getRedisOptions } = require("./utils")
|
||||
const { addDbPrefix, removeDbPrefix, getRedisOptions, SEPARATOR } = require("./utils")
|
||||
|
||||
const RETRY_PERIOD_MS = 2000
|
||||
const STARTUP_TIMEOUT_MS = 5000
|
||||
|
@ -143,14 +143,15 @@ class RedisWrapper {
|
|||
CLIENT.disconnect()
|
||||
}
|
||||
|
||||
async scan() {
|
||||
async scan(key = "") {
|
||||
const db = this._db
|
||||
key = `${db}${SEPARATOR}${key}`
|
||||
let stream
|
||||
if (CLUSTERED) {
|
||||
let node = CLIENT.nodes("master")
|
||||
stream = node[0].scanStream({ match: db + "-*", count: 100 })
|
||||
stream = node[0].scanStream({ match: key + "*", count: 100 })
|
||||
} else {
|
||||
stream = CLIENT.scanStream({ match: db + "-*", count: 100 })
|
||||
stream = CLIENT.scanStream({ match: key + "*", count: 100 })
|
||||
}
|
||||
return promisifyStream(stream)
|
||||
}
|
||||
|
@ -182,6 +183,12 @@ class RedisWrapper {
|
|||
}
|
||||
}
|
||||
|
||||
async setExpiry(key, expirySeconds) {
|
||||
const db = this._db
|
||||
const prefixedKey = addDbPrefix(db, key)
|
||||
await CLIENT.expire(prefixedKey, expirySeconds)
|
||||
}
|
||||
|
||||
async delete(key) {
|
||||
const db = this._db
|
||||
await CLIENT.del(addDbPrefix(db, key))
|
||||
|
|
|
@ -11,8 +11,12 @@ exports.Databases = {
|
|||
INVITATIONS: "invitation",
|
||||
DEV_LOCKS: "devLocks",
|
||||
DEBOUNCE: "debounce",
|
||||
SESSIONS: "session",
|
||||
USER_CACHE: "users",
|
||||
}
|
||||
|
||||
exports.SEPARATOR = SEPARATOR
|
||||
|
||||
exports.getRedisOptions = (clustered = false) => {
|
||||
const [host, port] = REDIS_URL.split(":")
|
||||
const opts = {
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
const redis = require("../redis/authRedis")
|
||||
|
||||
const EXPIRY_SECONDS = 86400
|
||||
|
||||
async function getSessionsForUser(userId) {
|
||||
const client = await redis.getSessionClient()
|
||||
return client.scan(userId)
|
||||
}
|
||||
|
||||
function makeSessionID(userId, sessionId) {
|
||||
return `${userId}/${sessionId}`
|
||||
}
|
||||
|
||||
exports.createASession = async (userId, sessionId, token) => {
|
||||
const client = await redis.getSessionClient()
|
||||
await client.store(makeSessionID(userId, sessionId), token, EXPIRY_SECONDS)
|
||||
}
|
||||
|
||||
exports.invalidateSessions = async (userId, sessionId = null) => {
|
||||
let sessions = []
|
||||
if (sessionId) {
|
||||
sessions.push({ key: makeSessionID(userId, sessionId) })
|
||||
} else {
|
||||
sessions = await getSessionsForUser(userId)
|
||||
}
|
||||
const client = await redis.getSessionClient()
|
||||
const promises = []
|
||||
for (let session of sessions) {
|
||||
promises.push(client.delete(session.key))
|
||||
}
|
||||
await Promise.all(promises)
|
||||
}
|
||||
|
||||
exports.updateSessionTTL = async (userId, sessionId) => {
|
||||
const client = await redis.getSessionClient()
|
||||
await client.setExpiry(makeSessionID(userId, sessionId), EXPIRY_SECONDS)
|
||||
}
|
||||
|
||||
exports.endSession = async (userId, sessionId) => {
|
||||
const client = await redis.getSessionClient()
|
||||
await client.delete(makeSessionID(userId, sessionId))
|
||||
}
|
||||
|
||||
exports.getSession = async (userId, sessionId) => {
|
||||
try {
|
||||
const client = await redis.getSessionClient()
|
||||
return client.get(makeSessionID(userId, sessionId))
|
||||
} catch (err) {
|
||||
// if can't get session don't error, just don't return anything
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
exports.getAllSessions = async () => {
|
||||
const client = await redis.getSessionClient()
|
||||
return client.scan()
|
||||
}
|
|
@ -64,23 +64,18 @@ exports.getCookie = (ctx, name) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* Store a cookie for the request, has a hardcoded expiry.
|
||||
* Store a cookie for the request - it will not expire.
|
||||
* @param {object} ctx The request which is to be manipulated.
|
||||
* @param {string} name The name of the cookie to set.
|
||||
* @param {string|object} value The value of cookie which will be set.
|
||||
*/
|
||||
exports.setCookie = (ctx, value, name = "builder") => {
|
||||
const expires = new Date()
|
||||
expires.setDate(expires.getDate() + 1)
|
||||
|
||||
if (!value) {
|
||||
ctx.cookies.set(name)
|
||||
} else {
|
||||
value = jwt.sign(value, options.secretOrKey, {
|
||||
expiresIn: "1 day",
|
||||
})
|
||||
value = jwt.sign(value, options.secretOrKey)
|
||||
ctx.cookies.set(name, value, {
|
||||
expires,
|
||||
maxAge: Number.MAX_SAFE_INTEGER,
|
||||
path: "/",
|
||||
httpOnly: false,
|
||||
overwrite: true,
|
||||
|
|
|
@ -6,6 +6,7 @@ const { getGlobalSelf } = require("../utilities/workerRequests")
|
|||
const { BUILTIN_ROLE_IDS } = require("@budibase/auth/roles")
|
||||
const { generateUserMetadataID } = require("../db/utils")
|
||||
const { dbExists } = require("@budibase/auth/db")
|
||||
const { getCachedSelf } = require("../utilities/global")
|
||||
const CouchDB = require("../db")
|
||||
|
||||
module.exports = async (ctx, next) => {
|
||||
|
@ -26,29 +27,16 @@ module.exports = async (ctx, next) => {
|
|||
}
|
||||
}
|
||||
|
||||
let updateCookie = false,
|
||||
appId,
|
||||
roleId = BUILTIN_ROLE_IDS.PUBLIC
|
||||
let appId, roleId = BUILTIN_ROLE_IDS.PUBLIC
|
||||
if (!ctx.user) {
|
||||
// not logged in, try to set a cookie for public apps
|
||||
updateCookie = true
|
||||
appId = requestAppId
|
||||
} else if (
|
||||
requestAppId != null &&
|
||||
(appCookie == null ||
|
||||
requestAppId !== appCookie.appId ||
|
||||
appCookie.roleId === BUILTIN_ROLE_IDS.PUBLIC ||
|
||||
!appCookie.roleId)
|
||||
) {
|
||||
} else if (requestAppId != null) {
|
||||
// Different App ID means cookie needs reset, or if the same public user has logged in
|
||||
const globalUser = await getGlobalSelf(ctx, requestAppId)
|
||||
updateCookie = true
|
||||
const globalUser = await getCachedSelf(ctx, requestAppId)
|
||||
appId = requestAppId
|
||||
// retrieving global user gets the right role
|
||||
roleId = globalUser.roleId || BUILTIN_ROLE_IDS.PUBLIC
|
||||
} else if (appCookie != null) {
|
||||
appId = appCookie.appId
|
||||
roleId = appCookie.roleId || BUILTIN_ROLE_IDS.PUBLIC
|
||||
}
|
||||
// nothing more to do
|
||||
if (!appId) {
|
||||
|
@ -68,8 +56,8 @@ module.exports = async (ctx, next) => {
|
|||
role: await getRole(appId, roleId),
|
||||
}
|
||||
}
|
||||
if (updateCookie) {
|
||||
setCookie(ctx, { appId, roleId }, Cookies.CurrentApp)
|
||||
if (requestAppId !== appId) {
|
||||
setCookie(ctx, { appId }, Cookies.CurrentApp)
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ const {
|
|||
const { BUILTIN_ROLE_IDS } = require("@budibase/auth/roles")
|
||||
const { getDeployedAppID } = require("@budibase/auth/db")
|
||||
const { getGlobalUserParams } = require("@budibase/auth/db")
|
||||
const { user: userCache } = require("@budibase/auth/cache")
|
||||
|
||||
exports.updateAppRole = (appId, user) => {
|
||||
if (!user.roles) {
|
||||
|
@ -25,15 +26,24 @@ exports.updateAppRole = (appId, user) => {
|
|||
return user
|
||||
}
|
||||
|
||||
exports.getGlobalUser = async (appId, userId) => {
|
||||
const db = CouchDB(StaticDatabases.GLOBAL.name)
|
||||
let user = await db.get(getGlobalIDFromUserMetadataID(userId))
|
||||
function processUser(appId, user) {
|
||||
if (user) {
|
||||
delete user.password
|
||||
}
|
||||
return exports.updateAppRole(appId, user)
|
||||
}
|
||||
|
||||
exports.getCachedSelf = async (ctx, appId) => {
|
||||
const user = await userCache.getUser(ctx.user._id)
|
||||
return processUser(appId, user)
|
||||
}
|
||||
|
||||
exports.getGlobalUser = async (appId, userId) => {
|
||||
const db = CouchDB(StaticDatabases.GLOBAL.name)
|
||||
let user = await db.get(getGlobalIDFromUserMetadataID(userId))
|
||||
return processUser(appId, user)
|
||||
}
|
||||
|
||||
exports.getGlobalUsers = async (appId = null, users = null) => {
|
||||
const db = CouchDB(StaticDatabases.GLOBAL.name)
|
||||
let globalUsers
|
||||
|
|
|
@ -10,7 +10,7 @@ const { checkResetPasswordCode } = require("../../../utilities/redis")
|
|||
|
||||
const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name
|
||||
|
||||
function authInternal(ctx, user, err = null) {
|
||||
async function authInternal(ctx, user, err = null) {
|
||||
if (err) {
|
||||
return ctx.throw(403, "Unauthorized")
|
||||
}
|
||||
|
@ -22,6 +22,7 @@ function authInternal(ctx, user, err = null) {
|
|||
return ctx.throw(403, "Unauthorized")
|
||||
}
|
||||
|
||||
// just store the user ID
|
||||
ctx.cookies.set(Cookies.Auth, user.token, {
|
||||
expires,
|
||||
path: "/",
|
||||
|
@ -32,7 +33,7 @@ function authInternal(ctx, user, err = null) {
|
|||
|
||||
exports.authenticate = async (ctx, next) => {
|
||||
return passport.authenticate("local", async (err, user) => {
|
||||
authInternal(ctx, user, err)
|
||||
await authInternal(ctx, user, err)
|
||||
|
||||
delete user.token
|
||||
|
||||
|
@ -123,7 +124,7 @@ exports.googleAuth = async (ctx, next) => {
|
|||
strategy,
|
||||
{ successRedirect: "/", failureRedirect: "/error" },
|
||||
async (err, user) => {
|
||||
authInternal(ctx, user, err)
|
||||
await authInternal(ctx, user, err)
|
||||
|
||||
ctx.redirect("/")
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@ const { hash, getGlobalUserByEmail } = require("@budibase/auth").utils
|
|||
const { UserStatus, EmailTemplatePurpose } = require("../../../constants")
|
||||
const { checkInviteCode } = require("../../../utilities/redis")
|
||||
const { sendEmail } = require("../../../utilities/email")
|
||||
const { user: userCache } = require("@budibase/auth/cache")
|
||||
const { invalidateSessions } = require("@budibase/auth/sessions")
|
||||
|
||||
const GLOBAL_DB = StaticDatabases.GLOBAL.name
|
||||
|
||||
|
@ -62,6 +64,7 @@ exports.save = async ctx => {
|
|||
password: hashedPassword,
|
||||
...user,
|
||||
})
|
||||
await userCache.invalidateUser(response.id)
|
||||
ctx.body = {
|
||||
_id: response.id,
|
||||
_rev: response.rev,
|
||||
|
@ -107,6 +110,8 @@ exports.destroy = async ctx => {
|
|||
const db = new CouchDB(GLOBAL_DB)
|
||||
const dbUser = await db.get(ctx.params.id)
|
||||
await db.remove(dbUser._id, dbUser._rev)
|
||||
await userCache.invalidateUser(dbUser._id)
|
||||
await invalidateSessions(dbUser._id)
|
||||
ctx.body = {
|
||||
message: `User ${ctx.params.id} deleted.`,
|
||||
}
|
||||
|
|
|
@ -44,7 +44,7 @@ async function getACode(db, code, deleteCode = true) {
|
|||
|
||||
exports.init = async () => {
|
||||
pwResetClient = await new Client(utils.Databases.PW_RESETS).init()
|
||||
invitationClient = await new Client(utils.Databases.PW_RESETS).init()
|
||||
invitationClient = await new Client(utils.Databases.INVITATIONS).init()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
Loading…
Reference in New Issue