Browse Source

Login system: Initial

Alan Colon 6 năm trước cách đây
mục cha
commit
5bb457121e
15 tập tin đã thay đổi với 763 bổ sung430 xóa
  1. 1 0
      .gitignore
  2. 29 0
      app/api.js
  3. 1 1
      app/index.html
  4. 17 6
      app/main.js
  5. 52 52
      app/main.vue
  6. 15 0
      app/security.js
  7. 34 0
      app/signin.vue
  8. 93 0
      app/signup.vue
  9. 1 5
      package.json
  10. 2 6
      server/app.js
  11. 40 13
      server/db.js
  12. 6 0
      server/log.js
  13. 38 0
      server/security.js
  14. 72 4
      server/server.js
  15. 362 343
      yarn.lock

+ 1 - 0
.gitignore

@@ -1,3 +1,4 @@
 node_modules/
 dist/
 rss-unlimited/
+.vscode/

+ 29 - 0
app/api.js

@@ -0,0 +1,29 @@
+const wrap = (fetch, options) => {
+  const fn = async (url, body) => {
+    const headers = {
+      'Accepts': 'application/json'
+    }
+    if (body) headers['Content-Type'] = 'application/json'
+    const result = await fetch(url, {
+      ...options,
+      headers: {
+        ...headers,
+        ...(options && options.headers)
+      },
+      body: body && JSON.stringify(body)
+    })
+    return await result.json()
+  }
+  Object.defineProperties(fn, {
+    get: { get: () => wrap(fetch, { ...options, method: 'GET' }) },
+    post: { get: () => wrap(fetch, { ...options, method: 'POST' }) },
+    put: { get: () => wrap(fetch, { ...options, method: 'PUT' }) },
+    patch: { get: () => wrap(fetch, { ...options, method: 'PATCH' }) },
+    delete: { get: () => wrap(fetch, { ...options, method: 'DELETE' }) }
+  })
+  return fn
+}
+
+const api = wrap(fetch)
+
+module.exports = api

+ 1 - 1
app/index.html

@@ -4,5 +4,5 @@
   <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no" />
   <link href='https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons' rel="stylesheet">
 </head>
-<body></body>
+<body><app /></body>
 </html>

+ 17 - 6
app/main.js

@@ -20,11 +20,17 @@ import Vuetify, {
   VListTileTitle,
   VList,
   VListGroup,
-  VDivider
+  VDivider,
+  VTextField,
+  VForm,
+  VBtn,
+  VAlert,
+  VLayout
 } from 'vuetify/lib'
 import { Ripple } from 'vuetify/lib/directives'
 
 Vue.use(VueRouter)
+
 Vue.use(Vuetify, {
   components: {
     VApp,
@@ -42,21 +48,26 @@ Vue.use(Vuetify, {
     VListTileTitle,
     VList,
     VListGroup,
-    VDivider
+    VDivider,
+    VForm,
+    VTextField,
+    VBtn,
+    VAlert,
+    VLayout
   },
   directives: {
     Ripple
   },
   theme: {
-    primary: colors.yellow.darken3, // #E53935
-    secondary: colors.yellow.lighten4, // #FFCDD2
-    accent: colors.indigo.base // #3F51B5
+    primary: colors.orange.darken3, // #E53935
+    secondary: colors.orange.lighten4, // #FFCDD2
+    accent: colors.indigo.base // #3F51B5,
   }
 })
 
 
 const component = new Vue({
-  el: document.body,
+  el: 'app',
   components: { Main },
   render: ce => ce('Main')
 })

+ 52 - 52
app/main.vue

@@ -1,10 +1,15 @@
 <script>
+const { jwt } = require('./security')
 import VueRouter from 'vue-router'
 import Home from './home.vue'
+import Signup from './signup.vue'
+import Signin from './signin.vue'
+
 const router = new VueRouter({
   routes: [
     { path: '', component: Home },
-    //{ path: '/signup', component: null }
+    { path: '/signup', component: Signup },
+    { path: '/signin', component: Signin }
   ]
 })
 
@@ -18,68 +23,63 @@ export default {
   router,
   data() {
     return {
-      drawer: false
+      drawer: null,
+      jwt
+    }
+  },
+  methods: {
+    signout() {
+      jwt.token = null
+      this.$router.push('/')
     }
   }
 }
 </script>
 
 <template>
-    <v-app>
-      <v-content>
-        <v-toolbar color="primary">
+    <v-app dark>
+      <v-navigation-drawer clipped fixed app v-model="drawer">
+        <!-- <v-toolbar flat>
           <v-toolbar-side-icon v-on:click="drawer = !drawer" />
           <v-toolbar-title>RSS Archive</v-toolbar-title>
-        </v-toolbar>
-        <v-navigation-drawer absolute temporary v-model="drawer">
-          <v-toolbar flat>
-            <v-toolbar-side-icon v-on:click="drawer = !drawer" />
-            <v-toolbar-title>RSS Archive</v-toolbar-title>
-          </v-toolbar>
-          <v-list>
-            <v-list-tile>
-              <v-list-tile-action>
-                <v-icon>home</v-icon>
-              </v-list-tile-action>
-              <v-list-tile-title>Home</v-list-tile-title>
-            </v-list-tile>
+        </v-toolbar> -->
+        <v-list>
+          <v-list-tile v-if="!jwt.token" to="/signin">
+            <v-list-tile-action>
+              <v-icon>account_circle</v-icon>
+            </v-list-tile-action>
+            <v-list-tile-title>Sign in</v-list-tile-title>
+          </v-list-tile>
 
-            <v-list-group
-              prepend-icon="account_circle"
-              value="true"
-            >
-              <template v-slot:activator>
-                <v-list-tile>
-                  <v-list-tile-title>Users</v-list-tile-title>
-                </v-list-tile>
-              </template>
-              <v-list-group
-                no-action
-                sub-group
-                value="true"
-              >
-                <template v-slot:activator>
-                  <v-list-tile>
-                    <v-list-tile-title>Admin</v-list-tile-title>
-                  </v-list-tile>
-                </template>
-              </v-list-group>
+          <v-list-tile v-if="!jwt.token" to="/signup">
+            <v-list-tile-action>
+              <v-icon>person_add</v-icon>
+            </v-list-tile-action>
+            <v-list-tile-title>Sign up</v-list-tile-title>
+          </v-list-tile>
 
-              <v-list-group
-                sub-group
-                no-action
-              >
-                <template v-slot:activator>
-                  <v-list-tile>
-                    <v-list-tile-title>Actions</v-list-tile-title>
-                  </v-list-tile>
-                </template>
-              </v-list-group>
-            </v-list-group>
-          </v-list>
-          <v-divider></v-divider>
+          <v-list-tile v-if="jwt.token" @click="signout">
+            <v-list-tile-action>
+              <v-icon>account_circle</v-icon>
+            </v-list-tile-action>
+            <v-list-tile-title>Sign out</v-list-tile-title>
+          </v-list-tile>
 
-        </v-navigation-drawer>
+          <v-list-tile to="/">
+            <v-list-tile-action>
+              <v-icon>home</v-icon>
+            </v-list-tile-action>
+            <v-list-tile-title>Home</v-list-tile-title>
+          </v-list-tile>
+        </v-list>
+        <v-divider></v-divider>
+
+      </v-navigation-drawer>
+      <v-toolbar app fixed clipped-left color="primary">
+        <v-toolbar-side-icon v-on:click="drawer = !drawer" />
+        <v-toolbar-title>RSS Archive</v-toolbar-title>
+      </v-toolbar>
+      <v-content>
         <v-container>
           <router-view />
         </v-container>

+ 15 - 0
app/security.js

@@ -0,0 +1,15 @@
+const jwt = Object.create({}, {
+  token: {
+    get: () => localStorage.getItem('token'),
+    set: (token) => token ? localStorage.setItem('token', token) : localStorage.removeItem('token'),
+    configurable: false,
+    enumerable: true
+  },
+  identity: {
+    get: () => JSON.parse((jwt.token && atob(jwt.token.split('.')[1])) || null),
+    configurable: false,
+    enumerable: true
+  }
+})
+
+module.exports = { jwt }

+ 34 - 0
app/signin.vue

@@ -0,0 +1,34 @@
+<script>
+const api = require('./api')
+const { jwt } = require('./security')
+export default {
+  data() {
+    return {
+      error: null,
+      model: {
+        email: null,
+        password: null
+      }
+    }
+  },
+  methods: {
+    async login() {
+      const result = await api.post('login', this.model)
+      if (result.error) {
+        this.error = result.error
+      } else {
+        jwt.token = result.token
+        this.$router.push('/dashboard')
+      }
+    }
+  }
+}
+</script>
+<template>
+  <v-form>
+    <v-text-field v-model="model.email" label="Email" />
+    <v-text-field v-model="model.password" type="password" label="Password" />
+    <v-btn @click="login">Sign in</v-btn>
+    <v-alert type="warning" dismissible :value="error">{{error}}</v-alert>
+  </v-form>
+</template>

+ 93 - 0
app/signup.vue

@@ -0,0 +1,93 @@
+<script>
+const { jwt } = require('./security')
+const api = require('./api')
+export default {
+  data() {
+    return {
+      valid: false,
+      saving: false,
+      error: null,
+      model: {
+        name: 'a@a', //null,
+        email: 'a@a', //null,
+        password: 'a@a', //null, // https://www.dashlane.com/hellointernet
+        confirmPassword: 'a@a', //null
+      },
+      rules: {
+        name: [
+          v => !!v || 'Name is required'
+        ],
+        email: [
+          v => !!v || 'E-mail is required',
+          v => /.+@.+/.test(v) || 'Valid email required'
+        ],
+        password: [
+          v => (v.length > 8
+          && /[A-Z]/.test(v)
+          && /[a-z]/.test(v)
+          && /\d/.test(v)
+          && /\W/.test(v))
+          || `Hey, your password is pretty weak. But whatever. I'm not your mother... I'm not going to force you. You should know better by now. Have you considered using <a href="https://www.dashlane.com/hellointernet">dashlane</a> for password management? Just think about it.`
+        ],
+        confirmPassword: [
+          v => v === this.model.password || 'Passwords do not match'
+        ]
+      }
+    }
+  },
+  computed: {
+    passwordMessages() {
+      const password = this.model.password || null
+      const weakPassword = password && (
+        password.length < 8
+        || !/[A-Z]/.test(password)
+        || !/[a-z]/.test(password)
+        || !/\d/.test(password)
+        || !/\W/.test(password)
+      )
+      return weakPassword
+      ? [`Hey, your password is pretty weak. But whatever. I'm not your mother... I'm not going to force you. You should know better by now. Have you considered using <a href="https://www.dashlane.com/hellointernet">dashlane</a> for password management? Just think about it.`]
+      : []
+    }
+  },
+  updated() {
+//    console.log('Validating', this.$refs.form.validate())
+    
+  },
+  methods: {
+    async submit() {
+      try {
+        this.saving = true
+        const result = await api.post('signup', this.model)
+        if (result.error) {
+          throw new Error(result.error)
+        }
+        jwt.token = result.token
+        this.$router.push('/dashboard')
+      } catch (err) {
+        this.saving = false
+        this.error = err.message || err
+      }
+    }
+  }
+}
+</script>
+
+<template>
+  <div>
+    <h1>Sign up</h1>
+    <v-form v-model="valid" ref="form">
+      <v-text-field v-model="model.name" :rules="rules.name" label="Your name" required />
+      <v-text-field v-model="model.email" :rules="rules.email" label="Email" required />
+      <v-text-field v-model="model.password" :messages="passwordMessages" label="Password" required type="password" />
+      <v-text-field v-model="model.confirmPassword" :rules="rules.confirmPassword" label="Confirm Password" required type="password" />
+      <v-btn :disabled="!valid || saving" color="success" @click="submit">
+        Create Account
+      </v-btn>
+      <v-alert :value="!!error" type="error" :dismissible="true">
+        {{error}}
+      </v-alert>
+      
+    </v-form>
+  </div>
+</template>

+ 1 - 5
package.json

@@ -38,11 +38,6 @@
   },
   "dependencies": {
     "@babel/core": "^7.4.4",
-    "angular": "^1.7.7",
-    "angular-animate": "^1.7.7",
-    "angular-aria": "^1.7.7",
-    "angular-material": "^1.1.12",
-    "angular-ui-router": "^1.0.20",
     "babel-loader": "^8.0.5",
     "body-parser": "^1.18.3",
     "chalk": "^2.4.2",
@@ -50,6 +45,7 @@
     "express": "^4.16.4",
     "express-ws": "^4.0.0",
     "file-loader": "^3.0.1",
+    "jsonwebtoken": "^8.5.1",
     "level": "^5.0.1",
     "material-icons": "^0.3.0",
     "morgan": "^1.9.1",

+ 2 - 6
server/app.js

@@ -97,7 +97,7 @@ const appProxy = new Proxy(userApp, {
                   const url = args[0] && args[0].url
                   console.warn(chalk.yellow(`Unhandled Promise Rejection (${url}): ${err}`))
                   try {
-                    args[1].status(500).send(err.toString())
+                    args[1].status(500).send({error: err.toString()})
                   } catch (err) {
                     console.warn(chalk.yellow(`Unable to send error response`))
                   }
@@ -128,8 +128,4 @@ app.use(userApp)
 userApp._userApp = userApp
 userApp._app = app
 
-module.exports = {
-  app: appProxy
-}
-
-module.exports = app
+module.exports = appProxy

+ 40 - 13
server/db.js

@@ -1,17 +1,44 @@
 const level = require('level')
-const db = level('rss-archive')
+const rootDb = level('rss-unlimited')
 
-const main = async () => {
-  await db.put('test', )
-  const val = await db.get('test')
-  console.log(val)
+const wrap = (db, prefix) => {
+  const locals = {
+    async get(key, ...args) {
+      try {
+        return JSON.parse(await db.get(prefix + key, ...args))
+      } catch (err) {
+        if (err.name === 'NotFoundError') return null
+        throw err
+      }
+    },
+    async put(key, value, ...args) {
+      return await db.put(prefix + key, JSON.stringify(value), ...args)
+    },
+    async del(key, ...args) {
+      return await db.del(prefix + key, ...args)
+    },
+    batch(array, ...args) {
+      if (array) {
+        array = array.map(item => ({
+          ...item,
+          key: prefix + item.key
+        }))
+        return db.batch(array, ...args)
+      } else {
+        return wrap(db.batch(), prefix)
+      }
+    },
+    toString() {
+      return `[DB ${prefix || '<root>'}]`
+    }
+  }
+  return new Proxy(db, {
+    get(db, key) {
+      if (locals[key]) return locals[key]
+      if (db[key]) return db[key]
+      return wrap(db, `${prefix}${key}.`)
+    }
+  })
 }
 
-
-main().catch(err => {
-  console.error(err)
-  process.exitCode = 1
-})
-.then(() => {
-  db.close()
-})
+module.exports = wrap(rootDb, '')

+ 6 - 0
server/log.js

@@ -0,0 +1,6 @@
+const log = o => {
+  if (typeof o !== 'object') throw new Error('log message should be an object')
+  if (!o.type) throw new Error('log message should have a type')
+  console.log(JSON.stringify(o))
+}
+module.exports = log

+ 38 - 0
server/security.js

@@ -0,0 +1,38 @@
+const crypto = require('crypto')
+const uuid = require('uuid/v4')
+const jwt = require('jsonwebtoken')
+const SECRET = 'scale action palace measure'
+const genSeed = uuid
+
+const hashPassword = ({password, seed, email}) => {
+  if (!password) throw new Error('Missing password')
+  if (!seed) throw new Error('Missing seed')
+  if (!email) throw new Error('Missing email')
+  email = email.toLowerCase().trim()
+  password = password.trim()
+  const hash = crypto.createHash('sha256')
+  hash.update(`${password},${seed},${email}`)
+  const result = hash.digest('base64')
+  return result
+}
+
+const createToken = user => jwt.sign(user, SECRET, { expiresIn: '2 days' })
+
+const validate = token => jwt.verify(token, SECRET, { complete: true })
+
+const authorize = (...claims) => (req, res, next) => {
+  const reg = /^Bearer (.*)$/.exec(req.headers.authorization)
+  let identity
+  if (reg && (identity = validate(token))) {
+    if (claims.every(claim => identity[claim])) {
+      req.identity = identity
+      next()
+    } else {
+      res.status(403).send('Access denied')
+    }
+  } else {
+    res.status(401).send('Authorization required')
+  }
+}
+
+module.exports = { hashPassword, genSeed, createToken, validate, authorize }

+ 72 - 4
server/server.js

@@ -1,6 +1,74 @@
 const app = require('./app')
+const db = require('./db')
+const log = require('./log')
+const { hashPassword, genSeed, authorize, createToken } = require('./security')
 
-app.listen().catch((err) => {
-  console.log(err.toString())
-  process.exit(1)
-})
+app.post('/signup', async (req, res) => {
+  if (!req.body.name) throw new Error('Missing name')
+  if (!req.body.password) throw new Error('Missing password')
+  if (!req.body.email) throw new Error('Missing email')
+  const email = req.body.email.toLowerCase().trim()
+  const existing = await db.users.get(email)
+  if (existing) throw new Error('User already exists.')
+  const seed = genSeed()
+  const password = hashPassword({password: req.body.password, email, seed})
+
+  const user = {
+    name: req.body.name,
+    email: req.body.email,
+    password,
+    seed,
+    ip: req.ip,
+    created: Date.now()
+  }
+
+  await db.users.put(email, user)
+
+  log({
+    type: 'user-created',
+    user
+  })
+
+  res.status(200).send({
+    token: createToken(user)
+  })
+})
+
+app.post('/login', async (req, res) => {
+  if (!req.body.password) throw new Error('Missing password')
+  if (!req.body.email) throw new Error('Missing email')
+  const email = req.body.email.toLowerCase().trim()
+  const user = await db.users.get(email)
+  if (user) {
+    const password = hashPassword({
+      password: req.body.password,
+      seed: user.seed,
+      email
+    })
+    if (password === user.password) {
+      log({ type: 'login', user})
+      res.status(200).send({
+        token: createToken(user)
+      })
+      return
+    }
+  }
+  res.status(400).send({
+    error: 'Login failed'
+  })
+})
+
+app.post('/renew', authorize(), async (req, res) => {
+  log({
+    type: 'renew',
+    user: req.identity
+  })
+  const identity = {...req.identity}
+  delete identity.eat
+  delete identity.iat
+  res.status(200).send({
+    token: createToken(identity)
+  })
+})
+
+app.start()

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 362 - 343
yarn.lock


Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác