@budibase/worker
Version:
Budibase background service
437 lines (385 loc) • 12.6 kB
text/typescript
import {
auth as authCore,
constants,
context,
events,
utils as utilsCore,
configs,
cache,
} from "@budibase/backend-core"
import {
ConfigType,
User,
Ctx,
LoginRequest,
SSOUser,
PasswordResetRequest,
PasswordResetUpdateRequest,
GoogleInnerConfig,
DatasourceAuthCookie,
LogoutResponse,
UserCtx,
SetInitInfoRequest,
GetInitInfoResponse,
PasswordResetResponse,
PasswordResetUpdateResponse,
SetInitInfoResponse,
LoginResponse,
} from "@budibase/types"
import env from "../../../environment"
import { Next } from "koa"
import * as authSdk from "../../../sdk/auth"
import * as userSdk from "../../../sdk/users"
const { Cookie, Header } = constants
const { passport, ssoCallbackUrl, google, oidc } = authCore
const { setCookie, getCookie, clearCookie } = utilsCore
// LOGIN / LOGOUT
const normalizeEmail = (e: string) => (e || "").toLowerCase()
const failKey = (email: string) => `auth:login:fail:${normalizeEmail(email)}`
const lockKey = (email: string) => `auth:login:lock:${normalizeEmail(email)}`
const isLocked = async (email: string) => {
return !!(await cache.get(lockKey(email)))
}
const handleLockoutResponse = (ctx: Ctx, email: string) => {
ctx.set("X-Account-Locked", "1")
ctx.set("Retry-After", String(env.LOGIN_LOCKOUT_SECONDS))
console.log(
`[auth] login blocked (post-failure) due to lock email=${normalizeEmail(email)}`
)
return ctx.throw(403, "Account temporarily locked. Try again later.")
}
const onFailed = async (email: string) => {
if (!email) return
const key = failKey(email)
const currentAttempt = Number((await cache.get(key)) || 0) || 0
const nextAttempt = currentAttempt + 1
await cache.store(key, nextAttempt, env.LOGIN_LOCKOUT_SECONDS)
console.log(
`[auth] failed login email=${normalizeEmail(email)} count=${nextAttempt}`
)
if (nextAttempt >= env.LOGIN_MAX_FAILED_ATTEMPTS) {
await cache.store(lockKey(email), "1", env.LOGIN_LOCKOUT_SECONDS)
await cache.destroy(key)
console.log(
`[auth] account locked email=${normalizeEmail(email)} for ${env.LOGIN_LOCKOUT_SECONDS}s`
)
}
}
const clearFailureState = async (email: string) => {
if (!email) return
await cache.destroy(failKey(email))
await cache.destroy(lockKey(email))
}
async function passportCallback(
ctx: Ctx,
user: User,
err: any = null,
info: { message: string } | null = null
) {
if (err) {
console.error("Authentication error", err)
console.trace(err)
return ctx.throw(403, info ? info : "Unauthorized")
}
if (!user) {
console.error("Authentication error - no user provided")
return ctx.throw(403, info ? info : "Unauthorized")
}
const loginResult = await authSdk.loginUser(user)
// set a cookie for browser access
setCookie(ctx, loginResult.token, Cookie.Auth, { sign: false })
// set the token in a header as well for APIs
ctx.set(Header.TOKEN, loginResult.token)
// add session invalidation info to response headers for frontend to handle
if (loginResult.invalidatedSessionCount > 0) {
ctx.set(
"X-Session-Invalidated-Count",
loginResult.invalidatedSessionCount.toString()
)
}
}
export const login = async (
ctx: Ctx<LoginRequest, LoginResponse>,
next: Next
) => {
const email = ctx.request.body.username
const dbUser = await userSdk.db.getUserByEmail(email)
if (dbUser && (await userSdk.db.isPreventPasswordActions(dbUser))) {
console.log(
`[auth] login prevented due to sso enforcement email=${normalizeEmail(email)}`
)
ctx.throw(403, "Invalid credentials")
}
return passport.authenticate(
"local",
async (err: any, user: User, info: any) => {
if (err || !user) {
if (dbUser) {
await onFailed(email)
}
if (await isLocked(email)) {
return handleLockoutResponse(ctx, email)
}
const reason =
(info && info.message) || (err && err.message) || "unknown"
console.log(
`[auth] password auth failed email=${normalizeEmail(email)} reason=${reason}`
)
// delegate to shared passport failure handling to preserve specific messages (e.g. expired)
return passportCallback(ctx, user as any, err, info)
}
await clearFailureState(email)
console.log(
`[auth] password auth success email=${normalizeEmail(user.email)}`
)
await passportCallback(ctx, user, err, info)
await context.identity.doInUserContext(user, ctx, async () => {
await events.auth.login("local", user.email)
})
ctx.body = {
message: "Login successful",
userId: user.userId,
}
}
)(ctx, next)
}
export const logout = async (ctx: UserCtx<void, LogoutResponse>) => {
if (ctx.user && ctx.user._id) {
await authSdk.logout({ ctx, userId: ctx.user._id })
}
ctx.body = { message: "User logged out." }
}
// INIT
export const setInitInfo = (
ctx: UserCtx<SetInitInfoRequest, SetInitInfoResponse>
) => {
const initInfo = ctx.request.body
setCookie(ctx, initInfo, Cookie.Init)
ctx.body = {
message: "Init info updated.",
}
}
export const getInitInfo = (ctx: UserCtx<void, GetInitInfoResponse>) => {
try {
ctx.body = getCookie(ctx, Cookie.Init) || {}
} catch (err) {
clearCookie(ctx, Cookie.Init)
ctx.body = {}
}
}
// PASSWORD MANAGEMENT
/**
* Reset the user password, used as part of a forgotten password flow.
*/
export const reset = async (
ctx: Ctx<PasswordResetRequest, PasswordResetResponse>
) => {
const { email } = ctx.request.body
const lcEmail = (email || "").toLowerCase()
const ip = (ctx.ip || "").toString()
// rate limit keys
const emailKey = `auth:pwdreset:email:${lcEmail}`
const ipKey = `auth:pwdreset:ip:${ip}`
const increment = async (key: string, windowSeconds: number) => {
const currentAttempt = Number((await cache.get(key)) || 0) || 0
const nextAttempt = currentAttempt + 1
await cache.store(key, nextAttempt, windowSeconds)
return nextAttempt
}
// apply per-email and per-ip rate limits
const nextEmail = await increment(
emailKey,
env.PASSWORD_RESET_RATE_EMAIL_WINDOW_SECONDS
)
const nextIp = await increment(
ipKey,
env.PASSWORD_RESET_RATE_IP_WINDOW_SECONDS
)
const emailLimited = nextEmail > env.PASSWORD_RESET_RATE_EMAIL_LIMIT
const ipLimited = nextIp > env.PASSWORD_RESET_RATE_IP_LIMIT
if (emailLimited || ipLimited) {
// surfaced for ui to display
ctx.set(
"X-RateLimit-Email-Limit",
String(env.PASSWORD_RESET_RATE_EMAIL_LIMIT)
)
ctx.set(
"X-RateLimit-Email-Remaining",
String(Math.max(env.PASSWORD_RESET_RATE_EMAIL_LIMIT - nextEmail, 0))
)
ctx.set("X-RateLimit-IP-Limit", String(env.PASSWORD_RESET_RATE_IP_LIMIT))
ctx.set(
"X-RateLimit-IP-Remaining",
String(Math.max(env.PASSWORD_RESET_RATE_IP_LIMIT - nextIp, 0))
)
// best-effort retry window
const retryAfter = Math.max(
env.PASSWORD_RESET_RATE_EMAIL_WINDOW_SECONDS,
env.PASSWORD_RESET_RATE_IP_WINDOW_SECONDS
)
ctx.set("Retry-After", String(retryAfter))
console.log(
`[auth] password reset rate limited email=${lcEmail} ip=${ip} emailCount=${nextEmail} ipCount=${nextIp}`
)
return ctx.throw(429, "Too many password reset requests. Try again later.")
}
await authSdk.reset(email)
ctx.body = {
message: "Please check your email for a reset link.",
}
}
/**
* Perform the user password update if the provided reset code is valid.
*/
export const resetUpdate = async (
ctx: Ctx<PasswordResetUpdateRequest, PasswordResetUpdateResponse>
) => {
const { resetCode, password } = ctx.request.body
try {
await authSdk.resetUpdate(resetCode, password)
ctx.body = {
message: "password reset successfully.",
}
} catch (err: any) {
console.warn(err)
// hide any details of the error for security
ctx.throw(400, err.message || "Cannot reset password.")
}
}
// DATASOURCE
export const datasourcePreAuth = async (
ctx: UserCtx<void, void>,
next: Next
) => {
const provider = ctx.params.provider
const { middleware } = require(`@budibase/backend-core`)
const handler = middleware.datasource[provider]
setCookie(
ctx,
{
provider,
appId: ctx.query.appId,
},
Cookie.DatasourceAuth
)
return handler.preAuth(passport, ctx, next)
}
export const datasourceAuth = async (ctx: UserCtx<void, void>, next: Next) => {
const authStateCookie = getCookie<DatasourceAuthCookie>(
ctx,
Cookie.DatasourceAuth
)
if (!authStateCookie) {
throw new Error("Unable to retrieve datasource authentication cookie")
}
const provider = authStateCookie.provider
const { middleware } = require(`@budibase/backend-core`)
const handler = middleware.datasource[provider]
return handler.postAuth(passport, ctx, next)
}
// GOOGLE SSO
export async function googleCallbackUrl(config?: GoogleInnerConfig) {
return ssoCallbackUrl(ConfigType.GOOGLE, config)
}
/**
* The initial call that google authentication makes to take you to the google login screen.
* On a successful login, you will be redirected to the googleAuth callback route.
*/
export const googlePreAuth = async (ctx: Ctx<void, void>, next: Next) => {
const config = await configs.getGoogleConfig()
if (!config) {
return ctx.throw(400, "Google config not found")
}
let callbackUrl = await googleCallbackUrl(config)
const strategy = await google.strategyFactory(
config,
callbackUrl,
userSdk.db.save
)
return passport.authenticate(strategy, {
scope: ["profile", "email"],
accessType: "offline",
prompt: "consent",
})(ctx, next)
}
export const googleCallback = async (ctx: Ctx<void, void>, next: Next) => {
const config = await configs.getGoogleConfig()
if (!config) {
return ctx.throw(400, "Google config not found")
}
const callbackUrl = await googleCallbackUrl(config)
const strategy = await google.strategyFactory(
config,
callbackUrl,
userSdk.db.save
)
return passport.authenticate(
strategy,
{
successRedirect: env.PASSPORT_GOOGLEAUTH_SUCCESS_REDIRECT,
failureRedirect: env.PASSPORT_GOOGLEAUTH_FAILURE_REDIRECT,
},
async (err: any, user: SSOUser, info: any) => {
await passportCallback(ctx, user, err, info)
await context.identity.doInUserContext(user, ctx, async () => {
await events.auth.login("google-internal", user.email)
})
ctx.redirect(env.PASSPORT_GOOGLEAUTH_SUCCESS_REDIRECT)
}
)(ctx, next)
}
// OIDC SSO
export async function oidcCallbackUrl() {
return ssoCallbackUrl(ConfigType.OIDC)
}
export const oidcStrategyFactory = async (ctx: any) => {
const config = await configs.getOIDCConfig()
if (!config) {
return ctx.throw(400, "OIDC config not found")
}
let callbackUrl = await oidcCallbackUrl()
//Remote Config
const enrichedConfig = await oidc.fetchStrategyConfig(config, callbackUrl)
return oidc.strategyFactory(enrichedConfig, userSdk.db.save)
}
/**
* The initial call that OIDC authentication makes to take you to the configured OIDC login screen.
* On a successful login, you will be redirected to the oidcAuth callback route.
*/
export const oidcPreAuth = async (ctx: Ctx<void, void>, next: Next) => {
const { configId } = ctx.params
if (!configId) {
ctx.throw(400, "OIDC config id is required")
}
const strategy = await oidcStrategyFactory(ctx)
setCookie(ctx, configId, Cookie.OIDC_CONFIG)
const config = await configs.getOIDCConfigById(configId)
if (!config) {
return ctx.throw(400, "OIDC config not found")
}
let authScopes =
config.scopes?.length > 0
? config.scopes
: ["profile", "email", "offline_access"]
return passport.authenticate(strategy, {
// required 'openid' scope is added by oidc strategy factory
scope: authScopes,
})(ctx, next)
}
export const oidcCallback = async (ctx: Ctx<void, void>, next: Next) => {
const strategy = await oidcStrategyFactory(ctx)
return passport.authenticate(
strategy,
{
successRedirect: env.PASSPORT_OIDCAUTH_SUCCESS_REDIRECT,
failureRedirect: env.PASSPORT_OIDCAUTH_FAILURE_REDIRECT,
},
async (err: any, user: SSOUser, info: any) => {
await passportCallback(ctx, user, err, info)
await context.identity.doInUserContext(user, ctx, async () => {
await events.auth.login("oidc", user.email)
})
ctx.redirect(env.PASSPORT_OIDCAUTH_SUCCESS_REDIRECT)
}
)(ctx, next)
}