├── .gitignore ├── frontend ├── static │ ├── favicon.ico │ └── README.md ├── .gitignore ├── middleware │ ├── authenticated.js │ └── README.md ├── plugins │ ├── axios.js │ └── README.md ├── components │ ├── README.md │ └── AppLogo.vue ├── .editorconfig ├── layouts │ ├── README.md │ ├── error.vue │ └── default.vue ├── pages │ ├── README.md │ ├── secure.vue │ ├── index.vue │ └── login.vue ├── assets │ └── README.md ├── store │ ├── README.md │ └── index.js ├── README.md ├── .eslintrc.js ├── package.json ├── nuxt.config.js └── server.js ├── backend ├── Pipfile ├── app.py └── Pipfile.lock ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .pyc 2 | __pycache__ 3 | node_modules 4 | -------------------------------------------------------------------------------- /frontend/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danjac/nuxt-python-secure-example/HEAD/frontend/static/favicon.ico -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # logs 5 | npm-debug.log 6 | 7 | # Nuxt build 8 | .nuxt 9 | 10 | # Nuxt generate 11 | dist 12 | -------------------------------------------------------------------------------- /frontend/middleware/authenticated.js: -------------------------------------------------------------------------------- 1 | export default function ({ store, redirect, route }) { 2 | if (!store.state.user) { 3 | return redirect('/login', {next: encodeURIComponent(route.fullPath)}) 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /frontend/plugins/axios.js: -------------------------------------------------------------------------------- 1 | export default function ({ $axios, redirect }) { 2 | $axios.onRequest(config => { 3 | config.xsrfHeaderName = 'x-csrf-token' 4 | config.xsrfCookieName = 'csrf-token' 5 | }) 6 | } 7 | -------------------------------------------------------------------------------- /backend/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.python.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | flask = "*" 10 | 11 | [requires] 12 | python_version = "3.6" 13 | -------------------------------------------------------------------------------- /frontend/components/README.md: -------------------------------------------------------------------------------- 1 | # COMPONENTS 2 | 3 | The components directory contains your Vue.js Components. 4 | Nuxt.js doesn't supercharge these components. 5 | 6 | **This directory is not required, you can delete it if you don't want to use it.** 7 | -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = space 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /frontend/layouts/README.md: -------------------------------------------------------------------------------- 1 | # LAYOUTS 2 | 3 | This directory contains your Application Layouts. 4 | 5 | More information about the usage of this directory in the documentation: 6 | https://nuxtjs.org/guide/views#layouts 7 | 8 | **This directory is not required, you can delete it if you don't want to use it.** 9 | -------------------------------------------------------------------------------- /frontend/pages/README.md: -------------------------------------------------------------------------------- 1 | # PAGES 2 | 3 | This directory contains your Application Views and Routes. 4 | The framework reads all the .vue files inside this directory and creates the router of your application. 5 | 6 | More information about the usage of this directory in the documentation: 7 | https://nuxtjs.org/guide/routing 8 | -------------------------------------------------------------------------------- /frontend/assets/README.md: -------------------------------------------------------------------------------- 1 | # ASSETS 2 | 3 | This directory contains your un-compiled assets such as LESS, SASS, or JavaScript. 4 | 5 | More information about the usage of this directory in the documentation: 6 | https://nuxtjs.org/guide/assets#webpacked 7 | 8 | **This directory is not required, you can delete it if you don't want to use it.** 9 | -------------------------------------------------------------------------------- /frontend/plugins/README.md: -------------------------------------------------------------------------------- 1 | # PLUGINS 2 | 3 | This directory contains your Javascript plugins that you want to run before instantiating the root vue.js application. 4 | 5 | More information about the usage of this directory in the documentation: 6 | https://nuxtjs.org/guide/plugins 7 | 8 | **This directory is not required, you can delete it if you don't want to use it.** 9 | -------------------------------------------------------------------------------- /frontend/static/README.md: -------------------------------------------------------------------------------- 1 | # STATIC 2 | 3 | This directory contains your static files. 4 | Each file inside this directory is mapped to /. 5 | 6 | Example: /static/robots.txt is mapped as /robots.txt. 7 | 8 | More information about the usage of this directory in the documentation: 9 | https://nuxtjs.org/guide/assets#static 10 | 11 | **This directory is not required, you can delete it if you don't want to use it.** 12 | -------------------------------------------------------------------------------- /frontend/pages/secure.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 18 | -------------------------------------------------------------------------------- /frontend/middleware/README.md: -------------------------------------------------------------------------------- 1 | # MIDDLEWARE 2 | 3 | This directory contains your Application Middleware. 4 | The middleware lets you define custom function to be ran before rendering a page or a group of pages (layouts). 5 | 6 | More information about the usage of this directory in the documentation: 7 | https://nuxtjs.org/guide/routing#middleware 8 | 9 | **This directory is not required, you can delete it if you don't want to use it.** 10 | -------------------------------------------------------------------------------- /frontend/store/README.md: -------------------------------------------------------------------------------- 1 | # STORE 2 | 3 | This directory contains your Vuex Store files. 4 | Vuex Store option is implemented in the Nuxt.js framework. 5 | Creating a index.js file in this directory activate the option in the framework automatically. 6 | 7 | More information about the usage of this directory in the documentation: 8 | https://nuxtjs.org/guide/vuex-store 9 | 10 | **This directory is not required, you can delete it if you don't want to use it.** 11 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # frontend 2 | 3 | > Nuxt.js project 4 | 5 | ## Build Setup 6 | 7 | ``` bash 8 | # install dependencies 9 | $ npm install # Or yarn install 10 | 11 | # serve with hot reload at localhost:3000 12 | $ npm run dev 13 | 14 | # build for production and launch server 15 | $ npm run build 16 | $ npm start 17 | 18 | # generate static project 19 | $ npm run generate 20 | ``` 21 | 22 | For detailed explanation on how things work, checkout the [Nuxt.js docs](https://github.com/nuxt/nuxt.js). 23 | -------------------------------------------------------------------------------- /frontend/layouts/error.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 17 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true 6 | }, 7 | parserOptions: { 8 | parser: 'babel-eslint' 9 | }, 10 | extends: [ 11 | // https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention 12 | // consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules. 13 | 'plugin:vue/essential' 14 | ], 15 | // required to lint *.vue files 16 | plugins: [ 17 | 'vue' 18 | ], 19 | // add your custom rules here 20 | rules: {} 21 | } 22 | -------------------------------------------------------------------------------- /frontend/pages/index.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /frontend/pages/login.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 39 | -------------------------------------------------------------------------------- /frontend/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | 4 | Vue.use(Vuex) 5 | 6 | const store = () => new Vuex.Store({ 7 | state: { 8 | user: null 9 | }, 10 | mutations: { 11 | SET_USER (state, user) { 12 | state.user = user 13 | } 14 | }, 15 | actions: { 16 | async nuxtServerInit ({ commit }, { req, app }) { 17 | if (req.session.authToken) { 18 | const data = await app.$axios.$get('/api/me/') 19 | commit('SET_USER', data) 20 | } else { 21 | commit('SET_USER', null) 22 | } 23 | }, 24 | async login ({ commit }, creds) { 25 | await this.$axios.$post('/auth/login/', creds) 26 | const data = await this.$axios.$get('/api/me/') 27 | commit('SET_USER', data) 28 | }, 29 | logout({ commit }) { 30 | commit('SET_USER', null) 31 | this.$axios.$post('/auth/logout/') 32 | } 33 | } 34 | }) 35 | 36 | export default store 37 | -------------------------------------------------------------------------------- /backend/app.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from flask import Flask, request, jsonify, abort 4 | 5 | app = Flask(__name__) 6 | 7 | 8 | def auth_required(fn): 9 | 10 | @functools.wraps(fn) 11 | def wrapper(*args, **kwargs): 12 | if request.headers.get("Authorization") == "Bearer 12345": 13 | return fn(*args, **kwargs) 14 | else: 15 | abort(401) 16 | 17 | return wrapper 18 | 19 | 20 | @app.route("/login/", methods=["POST"]) 21 | def login(): 22 | if ( 23 | request.json["username"] == "demo" 24 | and request.json["password"] == "demo" 25 | ): 26 | return jsonify({"token": "12345"}) 27 | abort(400) 28 | 29 | 30 | @app.route("/api/me/", methods=["GET"]) 31 | @auth_required 32 | def user_details(): 33 | return jsonify({"username": "demo", "email": "demo@gmail.com"}) 34 | 35 | 36 | @app.route("/api/secure/", methods=["GET"]) 37 | @auth_required 38 | def secure(): 39 | return jsonify({"message": "this is fine"}) 40 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "1.0.0", 4 | "description": "Nuxt.js project", 5 | "author": "danjac ", 6 | "private": true, 7 | "scripts": { 8 | "dev": "node server.js", 9 | "build": "nuxt build", 10 | "start": "NODE_ENV=production node server.js", 11 | "generate": "nuxt generate", 12 | "lint": "eslint --ext .js,.vue --ignore-path .gitignore .", 13 | "precommit": "npm run lint" 14 | }, 15 | "dependencies": { 16 | "@nuxtjs/axios": "^5.3.1", 17 | "@nuxtjs/toast": "^3.0.1", 18 | "body-parser": "^1.18.3", 19 | "connect-redis": "^3.3.3", 20 | "cookie-parser": "^1.4.3", 21 | "csurf": "^1.9.0", 22 | "express": "^4.16.3", 23 | "express-session": "^1.15.6", 24 | "http-proxy-middleware": "^0.18.0", 25 | "nuxt": "^1.0.0" 26 | }, 27 | "devDependencies": { 28 | "babel-eslint": "^8.2.1", 29 | "eslint": "^4.15.0", 30 | "eslint-friendly-formatter": "^3.0.0", 31 | "eslint-loader": "^1.7.1", 32 | "eslint-plugin-vue": "^4.0.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Dan Jacob 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/nuxt.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | /* 3 | ** Headers of the page 4 | */ 5 | head: { 6 | title: 'frontend', 7 | meta: [ 8 | { charset: 'utf-8' }, 9 | { name: 'viewport', content: 'width=device-width, initial-scale=1' }, 10 | { hid: 'description', name: 'description', content: 'Nuxt.js project' } 11 | ], 12 | link: [ 13 | { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' } 14 | ] 15 | }, 16 | axios: { 17 | // proxyHeaders: false 18 | }, 19 | modules: [ 20 | '@nuxtjs/axios', 21 | '@nuxtjs/toast', 22 | ], 23 | plugins: [ 24 | '~/plugins/axios' 25 | ], 26 | /* 27 | ** Customize the progress bar color 28 | */ 29 | loading: { color: '#3B8070' }, 30 | /* 31 | ** Build configuration 32 | */ 33 | build: { 34 | /* 35 | ** Run ESLint on save 36 | */ 37 | extend (config, { isDev, isClient }) { 38 | if (isDev && isClient) { 39 | config.module.rules.push({ 40 | enforce: 'pre', 41 | test: /\.(js|vue)$/, 42 | loader: 'eslint-loader', 43 | exclude: /(node_modules)/ 44 | }) 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /frontend/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 53 | -------------------------------------------------------------------------------- /frontend/components/AppLogo.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Handling secure authentication with a Python backend (e.g. Flask or Django/DRF) and Nuxt frontend. 2 | 3 | Overview 4 | -------- 5 | 6 | 1. User goes to login page (login.vue) and enters their credentials. 7 | 2. Server.js (node app running Nuxt) POSTs credentials to a Flask app. 8 | 3. Flask app returns with an auth token. 9 | 4. Node app stores token in session (using express-session). 10 | 5. Calls to /api/ are proxied to http://localhost:5000/ (URL of Flask app) using middleware. 11 | 6. If user is authenticated, we add the auth token in the session to the proxied request headers. 12 | 13 | Using Nuxt over plain VueJS incurs a number of advantages, most obviously server-side rendering (SSR) for fast-loading and better SEO. 14 | 15 | However, in addition, this allows for a cleaner architecture. Rather than mixing our Django/Flask REST app with our browser application, we can have a completely separate API app that can serve multiple clients - browsers, mobile, desktop, other servers - using the same authentication mechanism, without the additional complexity of managing an SPA wrapped inside Django or Flask templates. 16 | 17 | This is of course possible with a plain VueJS (client only) architecture, but the issue here is authentication. Storing tokens in the browser - whether JWT, DRF auth tokens, oauth2 tokens etc - is fraught with security risks, as localStorage, sessionStorage or client side cookies are all vulnerable to XSS attacks and should never be used for securing sensitive info. Using a node app "frontend" however does not incur such risk - we can safely store the auth token in a server cookie, completely opaque to the browser. As the node app is responsible for communicating with the API we don't need to worry about the auth tokens being exposed on the client. 18 | 19 | In addition, we've added CSRF protection using csurf middleware. 20 | -------------------------------------------------------------------------------- /backend/Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "cbf8e638e8cd1a98e1008cd9f9ead22772a784d607fbc4e41b89de5b19011179" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.6" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.python.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "click": { 20 | "hashes": [ 21 | "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d", 22 | "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b" 23 | ], 24 | "version": "==6.7" 25 | }, 26 | "flask": { 27 | "hashes": [ 28 | "sha256:2271c0070dbcb5275fad4a82e29f23ab92682dc45f9dfbc22c02ba9b9322ce48", 29 | "sha256:a080b744b7e345ccfcbc77954861cb05b3c63786e93f2b3875e0913d44b43f05" 30 | ], 31 | "index": "pypi", 32 | "version": "==1.0.2" 33 | }, 34 | "itsdangerous": { 35 | "hashes": [ 36 | "sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519" 37 | ], 38 | "version": "==0.24" 39 | }, 40 | "jinja2": { 41 | "hashes": [ 42 | "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", 43 | "sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" 44 | ], 45 | "version": "==2.10" 46 | }, 47 | "markupsafe": { 48 | "hashes": [ 49 | "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" 50 | ], 51 | "version": "==1.0" 52 | }, 53 | "werkzeug": { 54 | "hashes": [ 55 | "sha256:c3fd7a7d41976d9f44db327260e263132466836cef6f91512889ed60ad26557c", 56 | "sha256:d5da73735293558eb1651ee2fddc4d0dedcfa06538b8813a2e20011583c9e49b" 57 | ], 58 | "version": "==0.14.1" 59 | } 60 | }, 61 | "develop": {} 62 | } 63 | -------------------------------------------------------------------------------- /frontend/server.js: -------------------------------------------------------------------------------- 1 | const { Nuxt, Builder } = require('nuxt') 2 | const bodyParser = require('body-parser') 3 | const session = require('express-session') 4 | const csurf = require('csurf') 5 | const proxy = require('http-proxy-middleware') 6 | const axios = require('axios') 7 | const app = require('express')() 8 | const RedisStore = require('connect-redis')(session); 9 | 10 | const API_URI = process.env.API_URI || 'http://localhost:5000' 11 | 12 | let config = require('./nuxt.config') 13 | config.isDev = process.env.NODE_ENV !== 'production' 14 | 15 | app.use(session({ 16 | store: new RedisStore({ 17 | 18 | }), 19 | secret: 'super-secret-key', // TBD: grab from env 20 | resave: false, 21 | saveUninitialized: false, 22 | cookie: { 23 | maxAge: 1000 * 60 * 60 * 24, 24 | secure: !config.isDev, // require HTTPS in production 25 | } 26 | })) 27 | 28 | app.use(bodyParser.json()) 29 | 30 | app.use(csurf({ cookie: false })) 31 | 32 | // we'll just check the csrf token from the cookie (same usage as Django) 33 | // easier to use with Axios 34 | app.use((req, res, next) => { 35 | res.cookie('csrf-token', req.csrfToken()) 36 | next() 37 | }) 38 | 39 | app.use('/api', proxy({ 40 | target: API_URI, 41 | changeOrigin: true, 42 | // logLevel: 'debug', 43 | onProxyReq(proxyReq, req, res) { 44 | console.log('proxy session', req.session) 45 | if (req.session.authToken) { 46 | proxyReq.setHeader('Authorization', 'Bearer ' + req.session.authToken) 47 | } 48 | }, 49 | async onProxyRes(proxyRes, req, res) { 50 | // if the API returns a 401, the token can be considered invalid. Delete the token. 51 | if (proxyRes.statusCode === 401) { 52 | console.log('we have a 401, abort') 53 | delete req.session.authToken 54 | await req.session.save() 55 | } 56 | } 57 | })) 58 | 59 | app.post('/auth/login/', async (req, res) => { 60 | try { 61 | const result = await axios.post(API_URI + '/login/', req.body) 62 | req.session.authToken = result.data.token 63 | await req.session.save() 64 | return res.json({"OK": true}) 65 | } catch (e) { 66 | console.log(e) 67 | return res.status(401).json({ error: 'Bad credentials' }) 68 | } 69 | }) 70 | 71 | app.post('/auth/logout/', async (req, res) => { 72 | delete req.session.authToken 73 | await req.session.save() 74 | return res.status(200).json({ ok: true }) 75 | }) 76 | 77 | // instantiate Nuxt 78 | // make sure we load all modules, plugins etc 79 | const nuxt = new Nuxt(config) 80 | 81 | if (config.isDev) { 82 | new Builder(nuxt).build() 83 | } 84 | app.use(nuxt.render) 85 | app.listen(3000) 86 | console.log('Server listening on port 3000') 87 | --------------------------------------------------------------------------------