mirror of
https://github.com/wasp-lang/wasp.git
synced 2024-09-11 15:26:19 +03:00
Initial support for Cookie Session and CSRF
This commit is contained in:
parent
2a1fae3ecd
commit
fa66d1fe21
@ -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
|
||||
})
|
||||
|
||||
/**
|
||||
|
@ -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.
|
||||
//
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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(
|
||||
|
@ -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.
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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()
|
||||
})
|
||||
|
@ -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()
|
||||
})
|
@ -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)
|
||||
|
||||
|
31
waspc/data/Generator/templates/server/src/session.js
Normal file
31
waspc/data/Generator/templates/server/src/session.js
Normal 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))
|
||||
}
|
@ -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|]
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user