import debug from 'debug'; import { API_KEY_LAST_USED_INTERVAL, hashApiKey, isApiKey, isApiKeyBlockedPath, isApiKeyEnabled, } from '@/lib/api-key'; import { PARTIAL_AUTH_TOKEN_TYPE, ROLE_PERMISSIONS, ROLES, SHARE_CONTEXT_HEADER, SHARE_TOKEN_HEADER, SHARE_TOKEN_TYPE, } from '@/lib/constants'; import { createAuthKey, hash, secret } from '@/lib/crypto'; import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt'; import redis from '@/lib/redis'; import { ensureArray } from '@/lib/utils'; import { getApiKeyByHash, updateApiKeyLastUsed } from '@/queries/prisma/apiKey'; import { getUser } from '@/queries/prisma/user'; const log = debug('umami:auth'); export function getBearerToken(request: Request) { const auth = request.headers.get('authorization'); return auth?.split(' ')[1]; } export async function checkApiKeyAuth(request: Request, token: string) { const { pathname } = new URL(request.url); if (isApiKeyBlockedPath(pathname)) { log('API key not allowed for path', pathname); return null; } const apiKey = await getApiKeyByHash(hashApiKey(token)); if (!apiKey) { log('API key not found'); return null; } const user: any = await getUser(apiKey.userId); if (!user?.id) { log('API key user not found'); return null; } const lastUsedAt = apiKey.lastUsedAt?.getTime() ?? 0; if (Date.now() - lastUsedAt > API_KEY_LAST_USED_INTERVAL) { updateApiKeyLastUsed(apiKey.id).catch(e => log(e)); } delete user.password; user.isAdmin = user.role === ROLES.admin; return { token, user, authType: 'api-key' as const, apiKey: { id: apiKey.id, name: apiKey.name }, }; } export async function checkAuth(request: Request) { const token = getBearerToken(request); if (isApiKeyEnabled() && isApiKey(token)) { return checkApiKeyAuth(request, token); } const payload = parseSecureToken(token, secret()); // The partial token issued after the password step of a 2FA login only proves // knowledge of the password. It must never be accepted as a session; it is // only valid for completing the challenge at /api/2fa/verify. if (payload?.type === PARTIAL_AUTH_TOKEN_TYPE) { log('Partial auth token rejected'); return null; } const shareToken = await parseShareToken(request); let user = null; const { userId, authKey } = payload || {}; if (userId) { user = await getUser(userId, { includePassword: true }); // Reject tokens issued before the current password. // Allow legacy stateless tokens that were minted without a password fingerprint. if (user && payload.pwd && hash(user.password) !== payload.pwd) { user = null; } } else if (redis.enabled && authKey) { const key = await redis.client.get(authKey); if (key?.userId) { user = await getUser(key.userId, { includePassword: true }); // Only enforce password-change invalidation for sessions that include a password fingerprint. if (user && key.pwd && hash(user.password) !== key.pwd) { user = null; } } } log({ hasToken: !!token, hasPayload: !!payload, hasAuthKey: !!authKey, hasShareToken: !!shareToken, userId: user?.id, }); if (!user?.id && !shareToken) { log('User not authorized'); return null; } if (!user?.id && shareToken) { const shareContext = request.headers.get(SHARE_CONTEXT_HEADER); if (!shareContext) { log('Share token used outside share context'); return null; } } if (user) { delete user.password; user.isAdmin = user.role === ROLES.admin; } return { token, authKey, shareToken, user, authType: user ? ('session' as const) : ('share' as const), }; } export async function saveAuth(data: any, expire = 0) { const authKey = `auth:${createAuthKey()}`; if (redis.enabled) { await redis.client.set(authKey, data); if (expire) { await redis.client.expire(authKey, expire); } } return createSecureToken({ authKey }, secret()); } export async function hasPermission(role: string, permission: string | string[]) { return ensureArray(permission).some(e => ROLE_PERMISSIONS[role]?.includes(e)); } export function parseShareToken(request: Request) { try { const token: any = parseToken(request.headers.get(SHARE_TOKEN_HEADER), secret()); // Only accept tokens explicitly minted as share tokens. This prevents other // tokens signed with the same secret (e.g. the cache token from /api/send) // from being replayed as share tokens to gain analytics access. if (token?.type !== SHARE_TOKEN_TYPE) { return null; } return token; } catch (e) { log(e); return null; } }