Initial support for Cookie Session and CSRF

This commit is contained in:
shayneczyzewski 2022-06-09 12:44:34 -04:00
parent 2a1fae3ecd
commit fa66d1fe21
15 changed files with 172 additions and 116 deletions

View File

@ -3,46 +3,7 @@ import config from './config'
const api = axios.create({
baseURL: config.apiUrl,
})
const WASP_APP_AUTH_TOKEN_NAME = "authToken"
let authToken = null
if (window.localStorage) {
authToken = window.localStorage.getItem(WASP_APP_AUTH_TOKEN_NAME)
}
export const setAuthToken = (token) => {
if (typeof token !== 'string') {
throw Error(`Token must be a string, but it was: {${typeof token}} ${token}.`)
}
authToken = token
window.localStorage && window.localStorage.setItem(WASP_APP_AUTH_TOKEN_NAME, token)
}
export const clearAuthToken = () => {
authToken = undefined
window.localStorage && window.localStorage.removeItem(WASP_APP_AUTH_TOKEN_NAME)
}
export const clearLocalStorage = () => {
authToken = undefined
window.localStorage && window.localStorage.clear()
}
api.interceptors.request.use(request => {
if (authToken) {
request.headers['Authorization'] = `Bearer ${authToken}`
}
return request
})
api.interceptors.response.use(undefined, error => {
if (error.response?.status === 401) {
clearAuthToken()
}
return Promise.reject(error)
withCredentials: true
})
/**

View File

@ -1,13 +1,12 @@
import config from '../config.js'
import { removeQueries } from '../operations/resources'
import api, { setAuthToken, handleApiError } from '../api.js'
import api, { handleApiError } from '../api.js'
export default async function login(email, password) {
try {
const args = { email, password }
const response = await api.post(config.apiUrl + '/auth/login', args)
await api.post(config.apiUrl + '/auth/login', args)
setAuthToken(response.data.token)
// This isn't really neccessary because we remove all private queries after
// logout, but we do it to be extra safe.
//

View File

@ -1,9 +1,15 @@
import { clearLocalStorage } from '../api.js'
import { invalidateAndRemoveQueries } from '../operations/resources'
import config from '../config.js'
import api, { handleApiError } from '../api.js'
export default async function logout() {
clearLocalStorage()
// TODO(filip): We are currently invalidating and removing all the queries, but
// we should remove only the non-public, user-dependent ones.
await invalidateAndRemoveQueries()
try {
await api.post(config.apiUrl + '/auth/logout')
// TODO(filip): We are currently invalidating and removing all the queries, but
// we should remove only the non-public, user-dependent ones.
await invalidateAndRemoveQueries()
} catch (error) {
handleApiError(error)
}
}

View File

@ -11,7 +11,7 @@ async function getMe() {
return response.data
} catch (error) {
if (error.response?.status === 403) {
if (error.response?.status === 401 || error.response?.status === 403) {
return null
} else {
handleApiError(error)

View File

@ -2,6 +2,8 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { QueryClientProvider } from 'react-query'
import api, { handleApiError } from './api.js'
import config from './config.js'
import router from './router'
import {
@ -24,6 +26,8 @@ async function startApp() {
{=/ doesClientSetupFnExist =}
initializeQueryClient()
await setCsrfTokenHeader()
await render()
// If you want your app to work offline and load faster, you can change
@ -32,6 +36,21 @@ async function startApp() {
serviceWorker.unregister()
}
// NOTE: Since users will likely have the backend running on a different domain than
// the frontend, we are unable to set the token:
// (a) on the page load, as the index.html is not served by Node, nor
// (b) via a cookie, since the frontend JS will not be able to access a cross-domain cookie.
async function setCsrfTokenHeader() {
try {
const token = await api.get(config.apiUrl + '/csrf-token')
if (token.data) {
api.defaults.headers.common['X-CSRF-Token'] = token.data
}
} catch (error) {
handleApiError(error)
}
}
async function render() {
const queryClient = await queryClientInitialized
ReactDOM.render(

View File

@ -1,3 +1,4 @@
{{={= =}=}}
import express from 'express'
import cookieParser from 'cookie-parser'
import logger from 'morgan'
@ -6,6 +7,10 @@ import helmet from 'helmet'
import HttpError from './core/HttpError.js'
import indexRouter from './routes/index.js'
import config from './config.js'
{=# isAuthEnabled =}
import { useSession } from './session.js'
{=/ isAuthEnabled =}
// TODO: Consider extracting most of this logic into createApp(routes, path) function so that
// it can be used in unit tests to test each route individually.
@ -13,12 +18,23 @@ import indexRouter from './routes/index.js'
const app = express()
app.use(helmet())
app.use(cors()) // TODO: Consider configuring CORS to be more restrictive, right now it allows all CORS requests.
app.use(cors({
origin: config.frontendUrl,
credentials: true
}));
app.use(logger('dev'))
app.use(express.json())
app.use(express.urlencoded({ extended: false }))
app.use(cookieParser())
if (config.trustProxyCount > 0) {
app.set('trust proxy', config.trustProxyCount)
}
{=# isAuthEnabled =}
useSession(app)
{=/ isAuthEnabled =}
app.use('/', indexRouter)
// Custom error handler.

View File

@ -13,25 +13,35 @@ const config = {
env,
port: parseInt(process.env.PORT) || 3001,
databaseUrl: process.env.DATABASE_URL,
trustProxyCount: undefined,
{=# isAuthEnabled =}
auth: {
jwtSecret: undefined
}
session: {
name: process.env.SESSION_NAME || 'wasp_session',
secret: undefined,
cookie: {
maxAge: parseInt(process.env.SESSION_COOKIE_MAX_AGE) || 7 * 24 * 60 * 60 * 1000, // ms
},
},
{=/ isAuthEnabled =}
frontendUrl: undefined,
},
development: {
trustProxyCount: parseInt(process.env.TRUST_PROXY_COUNT) || 0,
{=# isAuthEnabled =}
auth: {
jwtSecret: 'DEVJWTSECRET'
}
session: {
secret: process.env.SESSION_SECRET || 'sessionSecret',
},
{=/ isAuthEnabled =}
frontendUrl: process.env.REACT_APP_URL || 'http://localhost:3000',
},
production: {
trustProxyCount: parseInt(process.env.TRUST_PROXY_COUNT) || 1,
{=# isAuthEnabled =}
auth: {
jwtSecret: process.env.JWT_SECRET
}
session: {
secret: process.env.SESSION_SECRET,
},
{=/ isAuthEnabled =}
frontendUrl: process.env.REACT_APP_URL,
}
}

View File

@ -1,55 +1,27 @@
{{={= =}=}}
import jwt from 'jsonwebtoken'
import SecurePassword from 'secure-password'
import util from 'util'
import prisma from '../dbClient.js'
import { handleRejection } from '../utils.js'
import config from '../config.js'
const jwtSign = util.promisify(jwt.sign)
const jwtVerify = util.promisify(jwt.verify)
const JWT_SECRET = config.auth.jwtSecret
export const sign = (id, options) => jwtSign({ id }, JWT_SECRET, options)
export const verify = (token) => jwtVerify(token, JWT_SECRET)
const auth = handleRejection(async (req, res, next) => {
const authHeader = req.get('Authorization')
if (!authHeader) {
// NOTE(matija): for now we let tokenless requests through and make it operation's
const user_id = req.session?.user_id
if (!user_id) {
// NOTE: for now we let requests without a user_id in the session through and make it operation's
// responsibility to verify whether the request is authenticated or not. In the future
// we will develop our own system at Wasp-level for that.
return next()
}
if (authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7, authHeader.length)
let userIdFromToken
try {
userIdFromToken = (await verify(token)).id
} catch (error) {
if (['TokenExpiredError', 'JsonWebTokenError', 'NotBeforeError'].includes(error.name)) {
return res.status(401).send()
} else {
throw error
}
}
const user = await prisma.{= userEntityLower =}.findUnique({ where: { id: userIdFromToken } })
if (!user) {
return res.status(401).send()
}
const { password, ...userView } = user
req.user = userView
} else {
const user = await prisma.{= userEntityLower =}.findUnique({ where: { id: user_id } })
if (!user) {
return res.status(401).send()
}
const { password, ...userView } = user
req.user = userView
next()
})
@ -70,4 +42,3 @@ export const verifyPassword = async (hashedPassword, password) => {
}
export default auth

View File

@ -2,14 +2,15 @@ import express from 'express'
import auth from '../../core/auth.js'
import login from './login.js'
import logout from './logout.js'
import signup from './signup.js'
import me from './me.js'
const router = express.Router()
router.post('/login', login)
router.post('/logout', logout)
router.post('/signup', signup)
router.get('/me', auth, me)
export default router

View File

@ -2,14 +2,13 @@
import Prisma from '@prisma/client'
import SecurePassword from 'secure-password'
import { sign, verifyPassword } from '../../core/auth.js'
import { verifyPassword } from '../../core/auth.js'
import { handleRejection } from '../../utils.js'
const prisma = new Prisma.PrismaClient()
export default handleRejection(async (req, res) => {
const args = req.body || {}
const context = {}
// Try to fetch user with the given email.
const {= userEntityLower =} = await prisma.{= userEntityLower =}.findUnique({ where: { email: args.email.toLowerCase() } })
@ -29,12 +28,8 @@ export default handleRejection(async (req, res) => {
return res.status(401).send()
}
// Email & password valid - generate token.
const token = await sign({= userEntityLower =}.id)
// Save user_id in session for future request use.
req.session = { user_id: {= userEntityLower =}.id }
// NOTE(matija): Possible option - instead of explicitly returning token here,
// we could add to response header 'Set-Cookie {token}' directive which would then make
// browser automatically save cookie with token.
return res.json({ token })
return res.status(200).send()
})

View File

@ -0,0 +1,8 @@
import { handleRejection } from '../../utils.js'
export default handleRejection(async (req, res) => {
// Destroy the session.
req.session = null
return res.status(200).send()
})

View File

@ -14,6 +14,12 @@ router.get('/', function (req, res, next) {
{=# isAuthEnabled =}
router.use('/auth', auth)
// NOTE: While we have CORS protection enabled in app.js, it is
// vitally important that this route always be CORS-protected.
router.get('/csrf-token', function (req, res) {
res.json(req.csrfToken())
})
{=/ isAuthEnabled =}
router.use('/{= operationsRouteInRootRouter =}', operations)

View File

@ -0,0 +1,31 @@
import cookieSession from 'cookie-session'
import csrf from 'csurf'
import config from './config.js'
const sessionConfig = {
name: config.session.name,
secret: config.session.secret,
httpOnly: true,
signed: true,
maxAge: config.session.cookie.maxAge,
}
const csrfConfig = {
cookie: {
key: 'wasp_csrf',
httpOnly: true,
},
}
export function useSession(app) {
if (config.env === 'production') {
sessionConfig.secure = true
sessionConfig.sameSite = 'none'
csrfConfig.cookie.secure = true
csrfConfig.cookie.sameSite = 'none'
}
app.use(cookieSession(sessionConfig))
app.use(csrf(csrfConfig))
}

View File

@ -25,12 +25,13 @@ import StrongPath
relfile,
(</>),
)
import qualified StrongPath as SP
import Wasp.AppSpec (AppSpec)
import qualified Wasp.AppSpec as AS
import qualified Wasp.AppSpec.App as AS.App
import qualified Wasp.AppSpec.App.Auth as AS.App.Auth
import qualified Wasp.AppSpec.App.Auth as AS.Auth
import qualified Wasp.AppSpec.App.Dependency as AS.Dependency
import qualified Wasp.AppSpec.App.Server as AS.App.Server
import qualified Wasp.AppSpec.App.Server as AS.Server
import qualified Wasp.AppSpec.Entity as AS.Entity
import Wasp.AppSpec.Util (isPgBossJobExecutorUsed)
import Wasp.AppSpec.Valid (getApp, isAuthEnabled)
@ -112,12 +113,12 @@ npmDepsForWasp spec =
("express", "~4.16.1"),
("morgan", "~1.9.1"),
("@prisma/client", show prismaVersion),
("jsonwebtoken", "^8.5.1"),
("secure-password", "^4.0.0"),
("dotenv", "8.2.0"),
("helmet", "^4.6.0")
]
++ depsRequiredByJobs spec,
++ depsRequiredByJobs spec
++ depsRequiredBySessions spec,
N.waspDevDependencies =
AS.Dependency.fromList
[ ("nodemon", "^2.0.4"),
@ -126,6 +127,17 @@ npmDepsForWasp spec =
]
}
depsRequiredBySessions :: AppSpec -> [AS.Dependency.Dependency]
depsRequiredBySessions spec =
let deps =
if isAuthEnabled spec
then
[ ("cookie-session", "~2.0.0"),
("csurf", "~1.11.0")
]
else []
in AS.Dependency.make <$> deps
genNpmrc :: Generator FileDraft
genNpmrc =
return $
@ -145,13 +157,14 @@ genGitignore =
genSrcDir :: AppSpec -> Generator [FileDraft]
genSrcDir spec =
sequence
[ copyTmplFile [relfile|app.js|],
copyTmplFile [relfile|utils.js|],
[ copyTmplFile [relfile|utils.js|],
copyTmplFile [relfile|session.js|],
copyTmplFile [relfile|core/AuthError.js|],
copyTmplFile [relfile|core/HttpError.js|],
genDbClient spec,
genConfigFile spec,
genServerJs spec
genServerJs spec,
genAppJs spec
]
<++> genRoutesDir spec
<++> genOperationsRoutes spec
@ -174,7 +187,7 @@ genDbClient spec = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmpl
then
object
[ "isAuthEnabled" .= True,
"userEntityUpper" .= (AS.refName (AS.App.Auth.userEntity $ fromJust maybeAuth) :: String)
"userEntityUpper" .= (AS.refName (AS.Auth.userEntity $ fromJust maybeAuth) :: String)
]
else object []
@ -193,11 +206,23 @@ genServerJs spec =
]
)
where
maybeSetupJsFunction = AS.App.Server.setupFn =<< AS.App.server (snd $ getApp spec)
maybeSetupJsFunction = AS.Server.setupFn =<< AS.App.server (snd $ getApp spec)
maybeSetupJsFnImportDetails = getJsImportDetailsForExtFnImport relPosixPathFromSrcDirToExtSrcDir <$> maybeSetupJsFunction
(maybeSetupJsFnImportIdentifier, maybeSetupJsFnImportStmt) =
(fst <$> maybeSetupJsFnImportDetails, snd <$> maybeSetupJsFnImportDetails)
genAppJs :: AppSpec -> Generator FileDraft
genAppJs spec = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData)
where
tmplFile = C.srcDirInServerTemplatesDir </> SP.castRel appFileInSrcDir
dstFile = C.serverSrcDirInServerRootDir </> appFileInSrcDir
tmplData =
object
[ "isAuthEnabled" .= (isAuthEnabled spec :: Bool)
]
appFileInSrcDir :: Path' (Rel C.ServerSrcDir) File'
appFileInSrcDir = [relfile|app.js|]
-- | TODO: Make this not hardcoded!
relPosixPathFromSrcDirToExtSrcDir :: Path Posix (Rel (Dir C.ServerSrcDir)) (Dir GeneratedExternalCodeDir)
relPosixPathFromSrcDirToExtSrcDir = [reldirP|./ext-src|]

View File

@ -24,6 +24,7 @@ genAuth spec = case maybeAuth of
-- Auth routes
genAuthRoutesIndex,
genLoginRoute auth,
genLogoutRoute,
genSignupRoute auth,
genMeRoute auth
]
@ -82,6 +83,13 @@ genLoginRoute auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tm
"userEntityLower" .= (Util.toLowerFirst userEntityName :: String)
]
genLogoutRoute :: Generator FileDraft
genLogoutRoute = return $ C.mkTmplFdWithDstAndData tmplFile dstFile Nothing
where
logoutRouteRelToSrc = [relfile|routes/auth/logout.js|]
tmplFile = C.asTmplFile $ [reldir|src|] </> logoutRouteRelToSrc
dstFile = C.serverSrcDirInServerRootDir </> C.asServerSrcFile logoutRouteRelToSrc
genSignupRoute :: AS.Auth.Auth -> Generator FileDraft
genSignupRoute auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData)
where