Merge pull request #3420 from sveltejs/cookies

use cookies instead of JWT
pull/3428/head
Rich Harris 5 years ago committed by GitHub
commit c8a44ec8d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -98,6 +98,5 @@
],
"sourceMap": true,
"instrument": true
},
"dependencies": {}
}
}

@ -5,8 +5,4 @@ BASEURL=
DATABASE_URL=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
MAPBOX_ACCESS_TOKEN=
JWT_EXP=30d
JWT_ALG=HS512
JWT_KEY=
MAPBOX_ACCESS_TOKEN=

@ -0,0 +1,15 @@
exports.up = DB => {
DB.sql(`
create table if not exists sessions (
uid uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
user_id integer REFERENCES users(id) not null,
expiry timestamp without time zone DEFAULT now() + interval '1 year'
);
`);
};
exports.down = DB => {
DB.sql(`
drop table if exists sessions;
`);
};

@ -1647,6 +1647,11 @@
"safe-buffer": "~5.1.1"
}
},
"cookie": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
"integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg=="
},
"core-js": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.5.tgz",
@ -1906,6 +1911,11 @@
"is-buffer": "~2.0.3"
}
},
"flru": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/flru/-/flru-1.0.2.tgz",
"integrity": "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog=="
},
"for-each": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",

@ -15,8 +15,10 @@
"dependencies": {
"@polka/redirect": "^1.0.0-next.0",
"@polka/send": "^1.0.0-next.3",
"cookie": "^0.4.0",
"devalue": "^2.0.0",
"do-not-zip": "^1.0.0",
"flru": "^1.0.2",
"httpie": "^1.1.2",
"jsonwebtoken": "^8.5.1",
"marked": "^0.7.0",

@ -1,150 +0,0 @@
import polka from 'polka';
import devalue from 'devalue';
import send from '@polka/send';
import { get, post } from 'httpie';
import { parse, stringify } from 'querystring';
import { decode, sign, verify } from './token';
import { find, query } from '../utils/db';
const {
BASEURL,
GITHUB_CLIENT_ID,
GITHUB_CLIENT_SECRET,
} = process.env;
const OAuth = 'https://github.com/login/oauth';
function exit(res, code, msg='') {
const error = msg.charAt(0).toUpperCase() + msg.substring(1);
send(res, code, { error });
}
function onError(err, req, res) {
const error = err.message || err;
const code = err.code || err.status || 500;
res.headersSent || send(res, code, { error });
}
/**
* Middleware to determine User validity
*/
export async function isUser(req, res) {
const abort = exit.bind(null, res, 401);
const auth = req.headers.authorization;
if (!auth) return abort('Missing Authorization header');
const [scheme, token] = auth.split(' ');
if (scheme !== 'Bearer' || !token) return abort('Invalid Authorization format');
let data;
const decoded = decode(token, { complete:true });
if (!decoded || !decoded.header) return abort('Invalid token');
try {
data = await verify(token);
} catch (err) {
return abort(err.message);
}
const { uid, username } = data;
if (!uid || !username) return abort('Invalid token payload');
try {
const row = await find(`select * from users where uid = $1 and username = $2 limit 1`, [uid, username]);
return row || abort('Invalid token');
} catch (err) {
console.error('Auth.isUser', err);
return send(res, 500, 'Unknown error occurred');
}
}
export function toUser(obj={}) {
const { uid, username, name, avatar } = obj;
const token = sign({ uid, username });
return { uid, username, name, avatar, token };
}
export function API() {
const app = polka({ onError });
if (GITHUB_CLIENT_ID) {
app.get('/auth/login', (req, res) => {
try {
const Location = `${OAuth}/authorize?` + stringify({
scope: 'read:user',
client_id: GITHUB_CLIENT_ID,
redirect_uri: `${BASEURL}/auth/callback`,
});
send(res, 302, Location, { Location });
} catch (err) {
console.error('GET /auth/login', err);
send(res, 500);
}
});
app.get('/auth/callback', async (req, res) => {
try {
// Trade "code" for "access_token"
const r1 = await post(`${OAuth}/access_token?` + stringify({
code: req.query.code,
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
}));
// Now fetch User details
const { access_token } = parse(r1.data);
const r2 = await get('https://api.github.com/user', {
headers: {
'User-Agent': 'svelte.dev',
Authorization: `token ${access_token}`
}
});
const { id, name, avatar_url, login } = r2.data;
// Upsert `users` table
const [user] = await query(`
insert into users(uid, name, username, avatar, github_token)
values ($1, $2, $3, $4, $5) on conflict (uid) do update
set (name, username, avatar, github_token, updated_at) = ($2, $3, $4, $5, now())
returning *
`, [id, name, login, avatar_url, access_token]);
send(res, 200, `
<script>
window.opener.postMessage({
user: ${devalue(toUser(user))}
}, window.location.origin);
</script>
`, {
'Content-Type': 'text/html; charset=utf-8'
});
} catch (err) {
console.error('GET /auth/callback', err);
send(res, 500, err.data, {
'Content-Type': err.headers['content-type'],
'Content-Length': err.headers['content-length']
});
}
});
} else {
// Print "Misconfigured" error
app.get('/auth/login', (req, res) => {
send(res, 500, `
<body style="font-family: sans-serif; background: rgb(255,215,215); border: 2px solid red; margin: 0; padding: 1em;">
<h1>Missing .env file</h1>
<p>In order to use GitHub authentication, you will need to <a target="_blank" href="https://github.com/settings/developers">register an OAuth application</a> and create a local .env file:</p>
<pre>GITHUB_CLIENT_ID=[YOUR_APP_ID]\nGITHUB_CLIENT_SECRET=[YOUR_APP_SECRET]\nBASEURL=http://localhost:3000</pre>
<p>The <code>BASEURL</code> variable should match the callback URL specified for your app.</p>
<p>See also <a target="_blank" href="https://github.com/sveltejs/svelte/tree/master/site#repl-github-integration">here</a></p>
</body>
`, {
'Content-Type': 'text/html; charset=utf-8'
});
});
}
return app;
}

@ -1,22 +0,0 @@
import * as jwt from 'jsonwebtoken';
const { JWT_KEY, JWT_ALG, JWT_EXP } = process.env;
const CONFIG = {
expiresIn: JWT_EXP,
issuer: 'https://svelte.dev',
audience: 'https://svelte.dev',
algorithm: JWT_ALG,
};
export const decode = jwt.decode;
export function sign(obj, opts, cb) {
opts = Object.assign({}, opts, CONFIG);
return jwt.sign(obj, JWT_KEY, opts, cb);
}
export function verify(str, opts, cb) {
opts = Object.assign({}, opts, CONFIG);
return jwt.verify(str, JWT_KEY, opts, cb);
}

@ -0,0 +1,6 @@
export const oauth = 'https://github.com/login/oauth';
export const baseurl = process.env.BASEURL;
export const secure = baseurl.startsWith('https:');
export const client_id = process.env.GITHUB_CLIENT_ID;
export const client_secret = process.env.GITHUB_CLIENT_SECRET;

@ -0,0 +1,54 @@
import send from '@polka/send';
import devalue from 'devalue';
import * as cookie from 'cookie';
import * as httpie from 'httpie';
import { parse, stringify } from 'querystring';
import { sanitize_user, create_user, create_session } from '../../utils/auth';
import { oauth, secure, client_id, client_secret } from './_config.js';
export async function get(req, res) {
try {
// Trade "code" for "access_token"
const r1 = await httpie.post(`${oauth}/access_token?` + stringify({
code: req.query.code,
client_id,
client_secret,
}));
// Now fetch User details
const { access_token } = parse(r1.data);
const r2 = await httpie.get('https://api.github.com/user', {
headers: {
'User-Agent': 'svelte.dev',
Authorization: `token ${access_token}`
}
});
const user = await create_user(r2.data, access_token);
const session = await create_session(user);
res.writeHead(200, {
'Set-Cookie': cookie.serialize('sid', session.uid, {
maxAge: 31536000,
path: '/',
httpOnly: true,
secure
}),
'Content-Type': 'text/html; charset=utf-8'
});
res.end(`
<script>
window.opener.postMessage({
user: ${devalue(sanitize_user(user))}
}, window.location.origin);
</script>
`);
} catch (err) {
console.error('GET /auth/callback', err);
send(res, 500, err.data, {
'Content-Type': err.headers['content-type'],
'Content-Length': err.headers['content-length']
});
}
}

@ -0,0 +1,27 @@
import send from '@polka/send';
import { stringify } from 'querystring';
import { oauth, baseurl, client_id } from './_config.js';
export const get = client_id
? (req, res) => {
const Location = `${oauth}/authorize?` + stringify({
scope: 'read:user',
client_id,
redirect_uri: `${baseurl}/auth/callback`,
});
send(res, 302, Location, { Location });
}
: (req, res) => {
send(res, 500, `
<body style="font-family: sans-serif; background: rgb(255,215,215); border: 2px solid red; margin: 0; padding: 1em;">
<h1>Missing .env file</h1>
<p>In order to use GitHub authentication, you will need to <a target="_blank" href="https://github.com/settings/developers">register an OAuth application</a> and create a local .env file:</p>
<pre>GITHUB_CLIENT_ID=[YOUR_APP_ID]\nGITHUB_CLIENT_SECRET=[YOUR_APP_SECRET]\nBASEURL=http://localhost:3000</pre>
<p>The <code>BASEURL</code> variable should match the callback URL specified for your app.</p>
<p>See also <a target="_blank" href="https://github.com/sveltejs/svelte/tree/master/site#repl-github-integration">here</a></p>
</body>
`, {
'Content-Type': 'text/html; charset=utf-8'
});
};

@ -0,0 +1,17 @@
import send from '@polka/send';
import * as cookie from 'cookie';
import { secure } from './_config.js';
import { delete_session } from '../../utils/auth.js';
export async function get(req, res) {
await delete_session(req.cookies.sid);
send(res, 200, '', {
'Set-Cookie': cookie.serialize('sid', '', {
maxAge: -1,
path: '/',
httpOnly: true,
secure
})
});
}

@ -1,8 +0,0 @@
import send from '@polka/send';
import { isUser, toUser } from '../../backend/auth';
export async function get(req, res) {
const user = await isUser(req, res);
res.setHeader('Cache-Control', 'private, no-cache, no-store');
return send(res, 200, user ? toUser(user) : null);
}

@ -1,15 +1,24 @@
<script>
import { user, logout } from '../../../../../user.js';
import { stores } from '@sapper/app';
const { session } = stores();
const logout = async () => {
const r = await fetch(`/auth/logout`, {
credentials: 'include'
});
if (r.ok) $session.user = null;
}
let showMenu = false;
let name;
$: name = $user.name || $user.username;
$: name = $session.user.name || $session.user.username;
</script>
<div class="user" on:mouseenter="{() => showMenu = true}" on:mouseleave="{() => showMenu = false}">
<span>{name}</span>
<img alt="{name} avatar" src="{$user.avatar}">
<img alt="{name} avatar" src="{$session.user.avatar}">
{#if showMenu}
<div class="menu">

@ -1,14 +1,15 @@
<script>
import { createEventDispatcher } from 'svelte';
import { stores } from '@sapper/app';
import UserMenu from './UserMenu.svelte';
import { Icon } from '@sveltejs/site-kit';
import * as doNotZip from 'do-not-zip';
import downloadBlob from '../../../_utils/downloadBlob.js';
import { user } from '../../../../../user.js';
import { enter } from '../../../../../utils/events.js';
import { isMac } from '../../../../../utils/compat.js';
const dispatch = createEventDispatcher();
const { session } = stores();
export let repl;
export let gist;
@ -25,8 +26,7 @@
return new Promise(f => setTimeout(f, ms));
}
$: Authorization = $user && `Bearer ${$user.token}`;
$: canSave = $user && gist && gist.owner === $user.uid;
$: canSave = $session.user && gist && gist.owner === $session.user.uid;
function handleKeydown(event) {
if (event.which === 83 && (isMac ? event.metaKey : event.ctrlKey)) {
@ -41,7 +41,7 @@
const handleLogin = event => {
loginWindow.close();
user.set(event.data.user);
$session.user = event.data.user;
window.removeEventListener('message', handleLogin);
};
@ -56,7 +56,7 @@
try {
const r = await fetch(`repl/create.json`, {
method: 'POST',
headers: { Authorization },
credentials: 'include',
body: JSON.stringify({
name,
files: components.map(component => ({
@ -111,7 +111,7 @@
const r = await fetch(`repl/${gist.uid}.json`, {
method: 'PATCH',
headers: { Authorization },
credentials: 'include',
body: JSON.stringify({
name,
files: components.map(component => ({
@ -199,7 +199,7 @@ export default app;` });
<Icon name="download" />
</button>
<button class="icon" disabled="{saving || !$user}" on:click={() => fork(false)} title="fork">
<button class="icon" disabled="{saving || !$session.user}" on:click={() => fork(false)} title="fork">
{#if justForked}
<Icon name="check" />
{:else}
@ -207,7 +207,7 @@ export default app;` });
{/if}
</button>
<button class="icon" disabled="{saving || !$user}" on:click={save} title="save">
<button class="icon" disabled="{saving || !$session.user}" on:click={save} title="save">
{#if justSaved}
<Icon name="check" />
{:else}
@ -215,8 +215,8 @@ export default app;` });
{/if}
</button>
{#if $user}
<UserMenu />
{#if $session.user}
<UserMenu/>
{:else}
<button class="icon" on:click={login}>
<Icon name="log-in" />

@ -2,7 +2,6 @@ import send from '@polka/send';
import body from '../_utils/body.js';
import * as httpie from 'httpie';
import { query, find } from '../../../utils/db';
import { isUser } from '../../../backend/auth';
import { get_example } from '../../examples/_examples.js';
const { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } = process.env;
@ -24,7 +23,7 @@ async function import_gist(req, res) {
if (!user) {
const { id, name, login, avatar_url } = data.owner;
[user] = await query(`
user = await find(`
insert into users(uid, name, username, avatar)
values ($1, $2, $3, $4)
returning *
@ -44,9 +43,10 @@ async function import_gist(req, res) {
});
// add gist to database...
const [gist] = await query(`
await query(`
insert into gists(uid, user_id, name, files)
values ($1, $2, $3, $4) returning *`, [req.params.id, user.id, data.description, JSON.stringify(files)]);
values ($1, $2, $3, $4)
`, [req.params.id, user.id, data.description, JSON.stringify(files)]);
send(res, 200, {
uid: req.params.id,
@ -92,10 +92,10 @@ export async function get(req, res) {
}
export async function patch(req, res) {
const user = await isUser(req, res);
if (!user) return; // response already sent
const { user } = req;
if (!user) return;
let id, uid=req.params.id;
let id, uid = req.params.id;
try {
const [row] = await query(`select * from gists where uid = $1 limit 1`, [uid]);

@ -10,14 +10,15 @@
<script>
import Repl from '@sveltejs/svelte-repl';
import { onMount } from 'svelte';
import { goto } from '@sapper/app';
import { user } from '../../../user.js';
import { goto, stores } from '@sapper/app';
import InputOutputToggle from '../../../components/Repl/InputOutputToggle.svelte';
import AppControls from './_components/AppControls/index.svelte';
export let version;
export let id;
const { session } = stores();
let repl;
let gist;
let name = 'Loading...';
@ -107,7 +108,7 @@
$: mobile = width < 540;
$: relaxed = is_relaxed_gist || ($user && gist && $user.uid === gist.owner);
$: relaxed = is_relaxed_gist || ($session.user && gist && $session.user.uid === gist.owner);
</script>
<style>

@ -1,10 +1,9 @@
import send from '@polka/send';
import body from './_utils/body.js';
import { query } from '../../utils/db';
import { isUser } from '../../backend/auth';
export async function post(req, res) {
const user = await isUser(req, res);
const { user } = req;
if (!user) return; // response already sent
try {

@ -1,21 +1,35 @@
import polka from 'polka';
import send from '@polka/send';
import sirv from 'sirv';
import * as sapper from '@sapper/server';
import { API } from './backend/auth';
import { sanitize_user, authenticate } from './utils/auth';
const { PORT = 3000 } = process.env;
API()
.use(
sirv('static', {
dev: process.env.NODE_ENV === 'development',
setHeaders(res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.hasHeader('Cache-Control') || res.setHeader('Cache-Control', 'max-age=600'); // 10min default
}
}),
const app = polka({
onError: (err, req, res) => {
const error = err.message || err;
const code = err.code || err.status || 500;
res.headersSent || send(res, code, { error });
}
});
sapper.middleware({
//
app.use(
authenticate(),
sirv('static', {
dev: process.env.NODE_ENV === 'development',
setHeaders(res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.hasHeader('Cache-Control') || res.setHeader('Cache-Control', 'max-age=600'); // 10min default
}
}),
sapper.middleware({
session: req => ({
user: sanitize_user(req.user)
})
)
.listen(PORT);
})
);
app.listen(PORT);

@ -1,33 +0,0 @@
import { writable } from 'svelte/store';
export const user = writable(null);
if (process.browser) {
const storageKey = 'svelte-dev:token';
// On load, get the last-known user token (if any)
// Note: We can skip this all by writing User data?
const token = localStorage.getItem(storageKey);
// Write changes to localStorage
user.subscribe(obj => {
if (obj) {
localStorage.setItem(storageKey, obj.token);
} else {
localStorage.removeItem(storageKey);
}
});
if (token) {
// If token, refresh the User data from API
const headers = { Authorization: `Bearer ${token}` };
fetch('/auth/me.json', { headers })
.then(r => r.ok ? r.json() : null)
.then(user.set);
}
}
export function logout() {
user.set(null);
}

@ -0,0 +1,65 @@
import * as cookie from 'cookie';
import flru from 'flru';
import { find, query } from './db';
export const sanitize_user = obj => obj && ({
uid: obj.uid,
username: obj.username,
name: obj.name,
avatar: obj.avatar
});
const session_cache = flru(1000);
export const create_user = async (gh_user, access_token) => {
return await find(`
insert into users(uid, name, username, avatar, github_token)
values ($1, $2, $3, $4, $5) on conflict (uid) do update
set (name, username, avatar, github_token, updated_at) = ($2, $3, $4, $5, now())
returning id, uid, username, name, avatar
`, [gh_user.id, gh_user.name, gh_user.login, gh_user.avatar_url, access_token]);
};
export const create_session = async user => {
const session = await find(`
insert into sessions(user_id)
values ($1)
returning uid
`, [user.id]);
session_cache.set(session.uid, user);
return session;
};
export const delete_session = async sid => {
await query(`delete from sessions where uid = $1`, [sid]);
session_cache.set(sid, null);
};
const get_user = async sid => {
if (!sid) return null;
if (!session_cache.has(sid)) {
session_cache.set(sid, await find(`
select users.id, users.uid, users.username, users.name, users.avatar
from sessions
left join users on sessions.user_id = users.id
where sessions.uid = $1 and expiry > now()
`, [sid]));
}
return session_cache.get(sid);
};
export const authenticate = () => {
// this is a convenient time to clear out expired sessions
query(`delete from sessions where expiry < now()`);
return async (req, res, next) => {
req.cookies = cookie.parse(req.headers.cookie || '');
req.user = await get_user(req.cookies.sid);
next();
};
};
Loading…
Cancel
Save