diff --git a/app/controller.js b/app/controller.js index e8d97523..d6e2c588 100644 --- a/app/controller.js +++ b/app/controller.js @@ -50,8 +50,8 @@ export default function(state, emitter) { state.user.login(email); }); - emitter.on('logout', () => { - state.user.logout(); + emitter.on('logout', async () => { + await state.user.logout(); metrics.loggedOut({ trigger: 'button' }); emitter.emit('pushState', '/'); }); @@ -179,6 +179,12 @@ export default function(state, emitter) { //cancelled. do nothing metrics.cancelledUpload(archive, err.duration); render(); + } else if (err.message === '401') { + const refreshed = await state.user.refresh(); + if (refreshed) { + return emitter.emit('upload'); + } + emitter.emit('pushState', '/error'); } else { // eslint-disable-next-line no-console console.error(err); diff --git a/app/ui/account.js b/app/ui/account.js index a81117e7..7f6430ec 100644 --- a/app/ui/account.js +++ b/app/ui/account.js @@ -54,12 +54,17 @@ class Account extends Component { createElement() { if (!this.enabled) { return html` -
+ `; } const user = this.state.user; const translate = this.state.translate; this.setLocal(); + if (user.loginRequired && !this.local.loggedIn) { + return html` + + `; + } if (!this.local.loggedIn) { return html` diff --git a/app/ui/download.js b/app/ui/download.js index 2a508ffd..92caa3ab 100644 --- a/app/ui/download.js +++ b/app/ui/download.js @@ -45,9 +45,7 @@ function preview(state, emit) { return noStreams(state, emit); } return html` -
+
diff --git a/app/ui/signupDialog.js b/app/ui/signupDialog.js index 5d4c85d6..23fe9a66 100644 --- a/app/ui/signupDialog.js +++ b/app/ui/signupDialog.js @@ -53,13 +53,17 @@ module.exports = function(trigger) { type="submit" /> - + ${state.user.loginRequired + ? '' + : html` + + `} `; diff --git a/app/user.js b/app/user.js index 419f98e3..272386d1 100644 --- a/app/user.js +++ b/app/user.js @@ -76,6 +76,10 @@ export default class User { return this.info.access_token; } + get refreshToken() { + return this.info.refresh_token; + } + get maxSize() { return this.loggedIn ? this.limits.MAX_FILE_SIZE @@ -139,6 +143,7 @@ export default class User { const code_challenge = await preparePkce(this.storage); const options = { action: 'email', + access_type: 'offline', client_id: this.authConfig.client_id, code_challenge, code_challenge_method: 'S256', @@ -196,12 +201,64 @@ export default class User { }); const userInfo = await infoResponse.json(); userInfo.access_token = auth.access_token; + userInfo.refresh_token = auth.refresh_token; userInfo.fileListKey = await getFileListKey(this.storage, auth.keys_jwe); this.info = userInfo; this.storage.remove('pkceVerifier'); } - logout() { + async refresh() { + if (!this.refreshToken) { + return false; + } + try { + const tokenResponse = await fetch(this.authConfig.token_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + client_id: this.authConfig.client_id, + grant_type: 'refresh_token', + refresh_token: this.refreshToken + }) + }); + const auth = await tokenResponse.json(); + this.info.access_token = auth.access_token; + return true; + } catch (e) { + return false; + } + } + + async logout() { + try { + if (this.refreshToken) { + await fetch(this.authConfig.revocation_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + refresh_token: this.refreshToken + }) + }); + } + if (this.bearerToken) { + await fetch(this.authConfig.revocation_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + token: this.bearerToken + }) + }); + } + } catch (e) { + console.error(e); + // oh well, we tried + } this.storage.clearLocalFiles(); this.info = {}; } @@ -215,17 +272,29 @@ export default class User { const key = b64ToArray(this.info.fileListKey); const sha = await crypto.subtle.digest('SHA-256', key); const kid = arrayToB64(new Uint8Array(sha)).substring(0, 16); + async function retry(e) { + if (e.message === '401') { + const refreshed = await this.refresh(); + if (refreshed) { + return await this.syncFileList(); + } else { + await this.logout(); + return { incoming: true }; + } + } + } try { - const encrypted = await getFileList(this.bearerToken, kid); + const encrypted = await getFileList( + this.bearerToken, + this.refreshToken, + kid + ); const decrypted = await streamToArrayBuffer( decryptStream(blobStream(encrypted), key) ); list = JSON.parse(textDecoder.decode(decrypted)); } catch (e) { - if (e.message === '401') { - this.logout(); - return { incoming: true }; - } + return retry(e); } changes = await this.storage.merge(list); if (!changes.outgoing) { @@ -238,9 +307,9 @@ export default class User { const encrypted = await streamToArrayBuffer( encryptStream(blobStream(blob), key) ); - await setFileList(this.bearerToken, kid, encrypted); + await setFileList(this.bearerToken, this.refreshToken, kid, encrypted); } catch (e) { - // + return retry(e); } return changes; } diff --git a/server/middleware/auth.js b/server/middleware/auth.js index 2e3ecfc4..9e525552 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -70,6 +70,10 @@ module.exports = { const token = authHeader.split(' ')[1]; req.user = await fxa.verify(token); } - return next(); + if (req.user) { + next(); + } else { + res.sendStatus(401); + } } }; diff --git a/server/routes/filelist.js b/server/routes/filelist.js index 700fe745..043c8714 100644 --- a/server/routes/filelist.js +++ b/server/routes/filelist.js @@ -13,9 +13,6 @@ function id(user, kid) { module.exports = { async get(req, res) { - if (!req.user) { - return res.sendStatus(401); - } const kid = req.params.id; try { const fileId = id(req.user, kid); @@ -32,9 +29,6 @@ module.exports = { }, async post(req, res) { - if (!req.user) { - return res.sendStatus(401); - } const kid = req.params.id; try { const limiter = new Limiter(1024 * 1024 * 10); diff --git a/server/routes/ws.js b/server/routes/ws.js index 4d89d875..f56fad1d 100644 --- a/server/routes/ws.js +++ b/server/routes/ws.js @@ -41,13 +41,20 @@ module.exports = function(ws, req) { ? config.max_downloads : config.anon_max_downloads; + if (config.fxa_required && !user) { + ws.send( + JSON.stringify({ + error: 401 + }) + ); + return ws.close(); + } if ( !metadata || !auth || timeLimit <= 0 || timeLimit > maxExpireSeconds || - dlimit > maxDownloads || - (config.fxa_required && !user) + dlimit > maxDownloads ) { ws.send( JSON.stringify({ diff --git a/test/frontend/tests/workflow-tests.js b/test/frontend/tests/workflow-tests.js index d96e4ba0..3a9709c4 100644 --- a/test/frontend/tests/workflow-tests.js +++ b/test/frontend/tests/workflow-tests.js @@ -181,14 +181,15 @@ describe('Upload / Download flow', function() { it('can allow multiple downloads', async function() { const fs = new FileSender(); - const file = await fs.upload(archive); + const a = new Archive([blob]); + a.dlimit = 2; + const file = await fs.upload(a); const fr = new FileReceiver({ secretKey: file.toJSON().secretKey, id: file.id, nonce: file.keychain.nonce, requiresPassword: false }); - await file.changeLimit(2); await fr.getMetadata(); await fr.download(options); await file.updateDownloadCount();