Reset multi_auth cookies on error (#1957)
* multi_auth cookies check + reset * multi_auth cookies refresh * Expire cookies after 30 days This is the actual default for next-auth.session-token. * Collapse issues by default * Only refresh session cookie manually as anon * fix mangled merge --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
parent
71caa6d0fe
commit
74d99e9b74
@ -8,40 +8,31 @@ import { useQuery } from '@apollo/client'
|
||||
import { UserListRow } from '@/components/user-list'
|
||||
import Link from 'next/link'
|
||||
import AddIcon from '@/svgs/add-fill.svg'
|
||||
import { MultiAuthErrorBanner } from '@/components/banners'
|
||||
|
||||
const AccountContext = createContext()
|
||||
|
||||
const CHECK_ERRORS_INTERVAL_MS = 5_000
|
||||
|
||||
const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')
|
||||
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
|
||||
|
||||
const maybeSecureCookie = cookie => {
|
||||
return window.location.protocol === 'https:' ? cookie + '; Secure' : cookie
|
||||
}
|
||||
|
||||
export const AccountProvider = ({ children }) => {
|
||||
const { me } = useMe()
|
||||
const [accounts, setAccounts] = useState([])
|
||||
const [meAnon, setMeAnon] = useState(true)
|
||||
const [errors, setErrors] = useState([])
|
||||
|
||||
const updateAccountsFromCookie = useCallback(() => {
|
||||
try {
|
||||
const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie)
|
||||
const accounts = multiAuthCookie
|
||||
? JSON.parse(b64Decode(multiAuthCookie))
|
||||
: me ? [{ id: Number(me.id), name: me.name, photoId: me.photoId }] : []
|
||||
setAccounts(accounts)
|
||||
// required for backwards compatibility: sync cookie with accounts if no multi auth cookie exists
|
||||
// this is the case for sessions that existed before we deployed account switching
|
||||
if (!multiAuthCookie && !!me) {
|
||||
document.cookie = maybeSecureCookie(`multi_auth=${b64Encode(accounts)}; Path=/`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('error parsing cookies:', err)
|
||||
}
|
||||
const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie)
|
||||
const accounts = multiAuthCookie
|
||||
? JSON.parse(b64Decode(multiAuthCookie))
|
||||
: []
|
||||
setAccounts(accounts)
|
||||
}, [])
|
||||
|
||||
useEffect(updateAccountsFromCookie, [])
|
||||
|
||||
const addAccount = useCallback(user => {
|
||||
setAccounts(accounts => [...accounts, user])
|
||||
}, [])
|
||||
@ -59,15 +50,43 @@ export const AccountProvider = ({ children }) => {
|
||||
return switchSuccess
|
||||
}, [updateAccountsFromCookie])
|
||||
|
||||
useEffect(() => {
|
||||
if (SSR) return
|
||||
const { 'multi_auth.user-id': multiAuthUserIdCookie } = cookie.parse(document.cookie)
|
||||
setMeAnon(multiAuthUserIdCookie === 'anonymous')
|
||||
const checkErrors = useCallback(() => {
|
||||
const {
|
||||
multi_auth: multiAuthCookie,
|
||||
'multi_auth.user-id': multiAuthUserIdCookie
|
||||
} = cookie.parse(document.cookie)
|
||||
|
||||
const errors = []
|
||||
|
||||
if (!multiAuthCookie) errors.push('multi_auth cookie not found')
|
||||
if (!multiAuthUserIdCookie) errors.push('multi_auth.user-id cookie not found')
|
||||
|
||||
setErrors(errors)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (SSR) return
|
||||
|
||||
updateAccountsFromCookie()
|
||||
|
||||
const { 'multi_auth.user-id': multiAuthUserIdCookie } = cookie.parse(document.cookie)
|
||||
setMeAnon(multiAuthUserIdCookie === 'anonymous')
|
||||
|
||||
const interval = setInterval(checkErrors, CHECK_ERRORS_INTERVAL_MS)
|
||||
return () => clearInterval(interval)
|
||||
}, [updateAccountsFromCookie, checkErrors])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ accounts, addAccount, removeAccount, meAnon, setMeAnon, nextAccount }),
|
||||
[accounts, addAccount, removeAccount, meAnon, setMeAnon, nextAccount])
|
||||
() => ({
|
||||
accounts,
|
||||
addAccount,
|
||||
removeAccount,
|
||||
meAnon,
|
||||
setMeAnon,
|
||||
nextAccount,
|
||||
multiAuthErrors: errors
|
||||
}),
|
||||
[accounts, addAccount, removeAccount, meAnon, setMeAnon, nextAccount, errors])
|
||||
return <AccountContext.Provider value={value}>{children}</AccountContext.Provider>
|
||||
}
|
||||
|
||||
@ -129,9 +148,23 @@ const AccountListRow = ({ account, ...props }) => {
|
||||
}
|
||||
|
||||
export default function SwitchAccountList () {
|
||||
const { accounts } = useAccounts()
|
||||
const { accounts, multiAuthErrors } = useAccounts()
|
||||
const router = useRouter()
|
||||
|
||||
const hasError = multiAuthErrors.length > 0
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<>
|
||||
<div className='my-2'>
|
||||
<div className='d-flex flex-column flex-wrap mt-2 mb-3'>
|
||||
<MultiAuthErrorBanner errors={multiAuthErrors} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// can't show hat since the streak is not included in the JWT payload
|
||||
return (
|
||||
<>
|
||||
|
@ -6,6 +6,7 @@ import { useMutation } from '@apollo/client'
|
||||
import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
|
||||
import { useToast } from '@/components/toast'
|
||||
import Link from 'next/link'
|
||||
import AccordianItem from '@/components/accordian-item'
|
||||
|
||||
export function WelcomeBanner ({ Banner }) {
|
||||
const { me } = useMe()
|
||||
@ -123,3 +124,24 @@ export function AuthBanner () {
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
export function MultiAuthErrorBanner ({ errors }) {
|
||||
return (
|
||||
<Alert className={styles.banner} key='info' variant='danger'>
|
||||
<div className='fw-bold mb-3'>Account switching is currently unavailable</div>
|
||||
<AccordianItem
|
||||
className='my-3'
|
||||
header='We have detected the following issues:'
|
||||
headerColor='var(--bs-danger-text-emphasis)'
|
||||
body={
|
||||
<ul>
|
||||
{errors.map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
<div className='mt-3'>To resolve these issues, please sign out and sign in again.</div>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
161
lib/auth.js
Normal file
161
lib/auth.js
Normal file
@ -0,0 +1,161 @@
|
||||
import { datePivot } from '@/lib/time'
|
||||
import * as cookie from 'cookie'
|
||||
import { NodeNextRequest } from 'next/dist/server/base-http/node'
|
||||
import { encode as encodeJWT, decode as decodeJWT } from 'next-auth/jwt'
|
||||
|
||||
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
|
||||
const b64Decode = s => JSON.parse(Buffer.from(s, 'base64'))
|
||||
|
||||
const userJwtRegexp = /^multi_auth\.\d+$/
|
||||
|
||||
const HTTPS = process.env.NODE_ENV === 'production'
|
||||
const SESSION_COOKIE_NAME = HTTPS ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
|
||||
|
||||
const cookieOptions = (args) => ({
|
||||
path: '/',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
// httpOnly cookies by default
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
// default expiration for next-auth JWTs is in 30 days
|
||||
expires: datePivot(new Date(), { days: 30 }),
|
||||
...args
|
||||
})
|
||||
|
||||
export function setMultiAuthCookies (req, res, { id, jwt, name, photoId }) {
|
||||
const httpOnlyOptions = cookieOptions()
|
||||
const jsOptions = { ...httpOnlyOptions, httpOnly: false }
|
||||
|
||||
// add JWT to **httpOnly** cookie
|
||||
res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${id}`, jwt, httpOnlyOptions))
|
||||
|
||||
// switch to user we just added
|
||||
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth.user-id', id, jsOptions))
|
||||
|
||||
let newMultiAuth = [{ id, name, photoId }]
|
||||
if (req.cookies.multi_auth) {
|
||||
const oldMultiAuth = b64Decode(req.cookies.multi_auth)
|
||||
// make sure we don't add duplicates
|
||||
if (oldMultiAuth.some(({ id: id_ }) => id_ === id)) return
|
||||
newMultiAuth = [...oldMultiAuth, ...newMultiAuth]
|
||||
}
|
||||
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', b64Encode(newMultiAuth), jsOptions))
|
||||
}
|
||||
|
||||
export function switchSessionCookie (request) {
|
||||
// switch next-auth session cookie with multi_auth cookie if cookie pointer present
|
||||
|
||||
// is there a cookie pointer?
|
||||
const cookiePointerName = 'multi_auth.user-id'
|
||||
const hasCookiePointer = !!request.cookies[cookiePointerName]
|
||||
|
||||
// is there a session?
|
||||
const hasSession = !!request.cookies[SESSION_COOKIE_NAME]
|
||||
|
||||
if (!hasCookiePointer || !hasSession) {
|
||||
// no session or no cookie pointer. do nothing.
|
||||
return request
|
||||
}
|
||||
|
||||
const userId = request.cookies[cookiePointerName]
|
||||
if (userId === 'anonymous') {
|
||||
// user switched to anon. only delete session cookie.
|
||||
delete request.cookies[SESSION_COOKIE_NAME]
|
||||
return request
|
||||
}
|
||||
|
||||
const userJWT = request.cookies[`multi_auth.${userId}`]
|
||||
if (!userJWT) {
|
||||
// no JWT for account switching found
|
||||
return request
|
||||
}
|
||||
|
||||
if (userJWT) {
|
||||
// use JWT found in cookie pointed to by cookie pointer
|
||||
request.cookies[SESSION_COOKIE_NAME] = userJWT
|
||||
return request
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
export function checkMultiAuthCookies (req, res) {
|
||||
if (!req.cookies.multi_auth || !req.cookies['multi_auth.user-id']) {
|
||||
return false
|
||||
}
|
||||
|
||||
const accounts = b64Decode(req.cookies.multi_auth)
|
||||
for (const account of accounts) {
|
||||
if (!req.cookies[`multi_auth.${account.id}`]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function resetMultiAuthCookies (req, res) {
|
||||
const httpOnlyOptions = cookieOptions({ expires: 0, maxAge: 0 })
|
||||
const jsOptions = { ...httpOnlyOptions, httpOnly: false }
|
||||
|
||||
if ('multi_auth' in req.cookies) res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', '', jsOptions))
|
||||
if ('multi_auth.user-id' in req.cookies) res.appendHeader('Set-Cookie', cookie.serialize('multi_auth.user-id', '', jsOptions))
|
||||
|
||||
for (const key of Object.keys(req.cookies)) {
|
||||
// reset all user JWTs
|
||||
if (userJwtRegexp.test(key)) {
|
||||
res.appendHeader('Set-Cookie', cookie.serialize(key, '', httpOnlyOptions))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshMultiAuthCookies (req, res) {
|
||||
const httpOnlyOptions = cookieOptions()
|
||||
const jsOptions = { ...httpOnlyOptions, httpOnly: false }
|
||||
|
||||
const refreshCookie = (name) => {
|
||||
res.appendHeader('Set-Cookie', cookie.serialize(name, req.cookies[name], jsOptions))
|
||||
}
|
||||
|
||||
const refreshToken = async (token) => {
|
||||
const secret = process.env.NEXTAUTH_SECRET
|
||||
return await encodeJWT({
|
||||
token: await decodeJWT({ token, secret }),
|
||||
secret
|
||||
})
|
||||
}
|
||||
|
||||
const isAnon = req.cookies['multi_auth.user-id'] === 'anonymous'
|
||||
|
||||
for (const [key, value] of Object.entries(req.cookies)) {
|
||||
// only refresh session cookie manually if we switched to anon since else it's already handled by next-auth
|
||||
if (key === SESSION_COOKIE_NAME && !isAnon) continue
|
||||
|
||||
if (!key.startsWith('multi_auth') && key !== SESSION_COOKIE_NAME) continue
|
||||
|
||||
if (userJwtRegexp.test(key) || key === SESSION_COOKIE_NAME) {
|
||||
const oldToken = value
|
||||
const newToken = await refreshToken(oldToken)
|
||||
res.appendHeader('Set-Cookie', cookie.serialize(key, newToken, httpOnlyOptions))
|
||||
continue
|
||||
}
|
||||
|
||||
refreshCookie(key)
|
||||
}
|
||||
}
|
||||
|
||||
export async function multiAuthMiddleware (req, res) {
|
||||
if (!req.cookies) {
|
||||
// required to properly access parsed cookies via req.cookies and not unparsed via req.headers.cookie
|
||||
req = new NodeNextRequest(req)
|
||||
}
|
||||
|
||||
const ok = checkMultiAuthCookies(req, res)
|
||||
if (!ok) {
|
||||
resetMultiAuthCookies(req, res)
|
||||
return switchSessionCookie(req)
|
||||
}
|
||||
|
||||
await refreshMultiAuthCookies(req, res)
|
||||
return switchSessionCookie(req)
|
||||
}
|
@ -8,14 +8,13 @@ import prisma from '@/api/models'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter'
|
||||
import { getToken, encode as encodeJWT } from 'next-auth/jwt'
|
||||
import { datePivot } from '@/lib/time'
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
import { notifyReferral } from '@/lib/webPush'
|
||||
import { hashEmail } from '@/lib/crypto'
|
||||
import * as cookie from 'cookie'
|
||||
import { multiAuthMiddleware } from '@/pages/api/graphql'
|
||||
import { multiAuthMiddleware, setMultiAuthCookies } from '@/lib/auth'
|
||||
import { BECH32_CHARSET } from '@/lib/constants'
|
||||
import { NodeNextRequest } from 'next/dist/server/base-http/node'
|
||||
import * as cookie from 'cookie'
|
||||
|
||||
/**
|
||||
* Stores userIds in user table
|
||||
@ -127,8 +126,8 @@ function getCallbacks (req, res) {
|
||||
token.sub = Number(token.id)
|
||||
}
|
||||
|
||||
// add multi_auth cookie for user that just logged in
|
||||
if (user && req && res) {
|
||||
// add multi_auth cookie for user that just logged in
|
||||
const secret = process.env.NEXTAUTH_SECRET
|
||||
const jwt = await encodeJWT({ token, secret })
|
||||
const me = await prisma.user.findUnique({ where: { id: token.id } })
|
||||
@ -147,37 +146,6 @@ function getCallbacks (req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
function setMultiAuthCookies (req, res, { id, jwt, name, photoId }) {
|
||||
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
|
||||
const b64Decode = s => JSON.parse(Buffer.from(s, 'base64'))
|
||||
|
||||
// default expiration for next-auth JWTs is in 1 month
|
||||
const expiresAt = datePivot(new Date(), { months: 1 })
|
||||
const secure = process.env.NODE_ENV === 'production'
|
||||
const cookieOptions = {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: 'lax',
|
||||
expires: expiresAt
|
||||
}
|
||||
|
||||
// add JWT to **httpOnly** cookie
|
||||
res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${id}`, jwt, cookieOptions))
|
||||
|
||||
// switch to user we just added
|
||||
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth.user-id', id, { ...cookieOptions, httpOnly: false }))
|
||||
|
||||
let newMultiAuth = [{ id, name, photoId }]
|
||||
if (req.cookies.multi_auth) {
|
||||
const oldMultiAuth = b64Decode(req.cookies.multi_auth)
|
||||
// make sure we don't add duplicates
|
||||
if (oldMultiAuth.some(({ id: id_ }) => id_ === id)) return
|
||||
newMultiAuth = [...oldMultiAuth, ...newMultiAuth]
|
||||
}
|
||||
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', b64Encode(newMultiAuth), { ...cookieOptions, httpOnly: false }))
|
||||
}
|
||||
|
||||
async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
|
||||
const { k1, pubkey } = credentials
|
||||
|
||||
@ -197,7 +165,7 @@ async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
|
||||
let user = await prisma.user.findUnique({ where: { [pubkeyColumnName]: pubkey } })
|
||||
|
||||
// make following code aware of cookie pointer for account switching
|
||||
req = multiAuthMiddleware(req)
|
||||
req = await multiAuthMiddleware(req, res)
|
||||
// token will be undefined if we're not logged in at all or if we switched to anon
|
||||
const token = await getToken({ req })
|
||||
if (!user) {
|
||||
@ -759,7 +727,7 @@ const newUserHtml = ({ url, token, site, email }) => {
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">Stacker News is like Reddit or Hacker News, but it <b>pays you Bitcoin</b>. Instead of giving posts or comments “upvotes,” Stacker News users (aka stackers) send you small amounts of Bitcoin called sats.</div>
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">Stacker News is like Reddit or Hacker News, but it <b>pays you Bitcoin</b>. Instead of giving posts or comments "upvotes," Stacker News users (aka stackers) send you small amounts of Bitcoin called sats.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@ -774,7 +742,7 @@ const newUserHtml = ({ url, token, site, email }) => {
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">If you’re not sure what to share, <a href="${dailyUrl}"><b><i>click here to introduce yourself to the community</i></b></a> with a comment on the daily discussion thread.</div>
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">If you're not sure what to share, <a href="${dailyUrl}"><b><i>click here to introduce yourself to the community</i></b></a> with a comment on the daily discussion thread.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@ -784,7 +752,7 @@ const newUserHtml = ({ url, token, site, email }) => {
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">If anything isn’t clear, comment on the FAQ post and we’ll answer your question.</div>
|
||||
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">If anything isn't clear, comment on the FAQ post and we'll answer your question.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
@ -11,7 +11,7 @@ import {
|
||||
ApolloServerPluginLandingPageLocalDefault,
|
||||
ApolloServerPluginLandingPageProductionDefault
|
||||
} from '@apollo/server/plugin/landingPage/default'
|
||||
import { NodeNextRequest } from 'next/dist/server/base-http/node'
|
||||
import { multiAuthMiddleware } from '@/lib/auth'
|
||||
|
||||
const apolloServer = new ApolloServer({
|
||||
typeDefs,
|
||||
@ -68,7 +68,7 @@ export default startServerAndCreateNextHandler(apolloServer, {
|
||||
session = { user: { ...sessionFields, apiKey: true } }
|
||||
}
|
||||
} else {
|
||||
req = multiAuthMiddleware(req)
|
||||
req = await multiAuthMiddleware(req, res)
|
||||
session = await getServerSession(req, res, getAuthOptions(req))
|
||||
}
|
||||
return {
|
||||
@ -82,49 +82,3 @@ export default startServerAndCreateNextHandler(apolloServer, {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export function multiAuthMiddleware (request) {
|
||||
// switch next-auth session cookie with multi_auth cookie if cookie pointer present
|
||||
|
||||
if (!request.cookies) {
|
||||
// required to properly access parsed cookies via request.cookies
|
||||
// and not unparsed via request.headers.cookie
|
||||
request = new NodeNextRequest(request)
|
||||
}
|
||||
|
||||
// is there a cookie pointer?
|
||||
const cookiePointerName = 'multi_auth.user-id'
|
||||
const hasCookiePointer = !!request.cookies[cookiePointerName]
|
||||
|
||||
const secure = process.env.NODE_ENV === 'production'
|
||||
|
||||
// is there a session?
|
||||
const sessionCookieName = secure ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
|
||||
const hasSession = !!request.cookies[sessionCookieName]
|
||||
|
||||
if (!hasCookiePointer || !hasSession) {
|
||||
// no session or no cookie pointer. do nothing.
|
||||
return request
|
||||
}
|
||||
|
||||
const userId = request.cookies[cookiePointerName]
|
||||
if (userId === 'anonymous') {
|
||||
// user switched to anon. only delete session cookie.
|
||||
delete request.cookies[sessionCookieName]
|
||||
return request
|
||||
}
|
||||
|
||||
const userJWT = request.cookies[`multi_auth.${userId}`]
|
||||
if (!userJWT) {
|
||||
// no JWT for account switching found
|
||||
return request
|
||||
}
|
||||
|
||||
if (userJWT) {
|
||||
// use JWT found in cookie pointed to by cookie pointer
|
||||
request.cookies[sessionCookieName] = userJWT
|
||||
return request
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user