Pārlūkot izejas kodu

feat: auth jwt, permissions, login ui (wip)

Nicolas Giard 6 gadi atpakaļ
vecāks
revīzija
3abd2f917c
53 mainītis faili ar 541 papildinājumiem un 429 dzēšanām
  1. 1 1
      Makefile
  2. 5 1
      client/client-app.js
  3. 15 13
      client/components/common/nav-footer.vue
  4. 6 1
      client/components/common/nav-header.vue
  5. 153 298
      client/components/login.vue
  6. 1 1
      client/graph/admin/auth/auth-query-strategies.gql
  7. 1 0
      client/graph/login/login-mutation-login.gql
  8. 3 2
      client/graph/login/login-query-strategies.gql
  9. 3 0
      client/scss/app.scss
  10. 7 0
      client/scss/base/base.scss
  11. 27 0
      client/scss/base/material.scss
  12. 7 0
      client/scss/layout/_md2.scss
  13. 5 1
      client/static/svg/auth-icon-auth0.svg
  14. 6 0
      client/static/svg/auth-icon-cas.svg
  15. 1 0
      client/static/svg/auth-icon-oidc.svg
  16. 5 0
      client/static/svg/auth-icon-okta.svg
  17. 5 0
      client/static/svg/auth-icon-saml.svg
  18. 2 15
      client/static/svg/motif-overlay.svg
  19. 1 1
      dev/templates/master.pug
  20. 2 0
      package.json
  21. 5 4
      server/controllers/auth.js
  22. 22 22
      server/core/auth.js
  23. 18 1
      server/db/migrations/2.0.0.js
  24. 54 0
      server/graph/directives/auth.js
  25. 6 1
      server/graph/index.js
  26. 1 5
      server/graph/resolvers/authentication.js
  27. 6 5
      server/graph/schemas/authentication.graphql
  28. 4 0
      server/graph/schemas/common.graphql
  29. 14 0
      server/helpers/security.js
  30. 2 15
      server/master.js
  31. 60 18
      server/middlewares/auth.js
  32. 4 4
      server/models/authentication.js
  33. 32 2
      server/models/users.js
  34. 1 0
      server/modules/authentication/auth0/definition.yml
  35. 1 0
      server/modules/authentication/azure/definition.yml
  36. 1 0
      server/modules/authentication/cas/definition.yml
  37. 1 0
      server/modules/authentication/discord/definition.yml
  38. 1 0
      server/modules/authentication/dropbox/definition.yml
  39. 1 0
      server/modules/authentication/facebook/definition.yml
  40. 1 0
      server/modules/authentication/github/definition.yml
  41. 1 0
      server/modules/authentication/google/definition.yml
  42. 1 0
      server/modules/authentication/ldap/definition.yml
  43. 1 0
      server/modules/authentication/local/definition.yml
  44. 1 0
      server/modules/authentication/microsoft/definition.yml
  45. 1 0
      server/modules/authentication/oauth2/definition.yml
  46. 1 0
      server/modules/authentication/oidc/definition.yml
  47. 1 0
      server/modules/authentication/okta/definition.yml
  48. 1 0
      server/modules/authentication/saml/definition.yml
  49. 1 0
      server/modules/authentication/slack/definition.yml
  50. 1 0
      server/modules/authentication/twitch/definition.yml
  51. 27 16
      server/setup.js
  52. 1 1
      server/views/master.pug
  53. 12 1
      yarn.lock

+ 1 - 1
Makefile

@@ -9,7 +9,7 @@ stop: ## Stop Wiki.js
 restart: ## Restart Wiki.js
 	node wiki restart
 
-dev: ## Start Wiki.js in development mode
+dev-up: ## Start Wiki.js in development mode
 	node wiki dev
 
 build: ## Build Wiki.js client assets

+ 5 - 1
client/client-app.js

@@ -22,6 +22,7 @@ import VueMoment from 'vue-moment'
 import VueTour from 'vue-tour'
 import VueTreeNavigation from 'vue-tree-navigation'
 import store from './store'
+import Cookies from 'js-cookie'
 
 // ====================================
 // Load Modules
@@ -74,7 +75,10 @@ const graphQLLink = createPersistedQueryLink().concat(
       options.body = JSON.stringify(body)
 
       // Inject authentication token
-      options.headers.Authorization = `Bearer TODO`
+      const jwtToken = Cookies.get('jwt')
+      if (jwtToken) {
+        options.headers.Authorization = `Bearer ${jwtToken}`
+      }
 
       return fetch(uri, options)
     }

+ 15 - 13
client/components/common/nav-footer.vue

@@ -1,14 +1,14 @@
 <template lang="pug">
-  v-footer.justify-center(:color='color', inset)
+  v-footer.justify-center(:color='bgColor', inset)
     .caption.grey--text.text--darken-1
       span(v-if='company && company.length > 0') {{ $t('common:footer.copyright', { company: company, year: currentYear, interpolation: { escapeValue: false } }) }} |&nbsp;
       span {{ $t('common:footer.poweredBy') }} #[a(href='https://wiki.js.org', ref='nofollow') Wiki.js]
 
     v-snackbar(
       :color='notification.style'
-      bottom,
-      right,
-      multi-line,
+      bottom
+      right
+      multi-line
       v-model='notificationState'
     )
       .text-xs-left
@@ -21,9 +21,13 @@ import { get, sync } from 'vuex-pathify'
 
 export default {
   props: {
-    altbg: {
-      type: Boolean,
-      default: false
+    color: {
+      type: String,
+      default: 'grey lighten-3'
+    },
+    darkColor: {
+      type: String,
+      default: 'grey darken-3'
     }
   },
   data() {
@@ -36,13 +40,11 @@ export default {
     notification: get('notification'),
     darkMode: get('site/dark'),
     notificationState: sync('notification@isActive'),
-    color() {
-      if (this.altbg) {
-        return 'altbg'
-      } else if (!this.darkMode) {
-        return 'grey lighten-3'
+    bgColor() {
+      if (!this.darkMode) {
+        return this.color
       } else {
-        return ''
+        return this.darkColor
       }
     }
   }

+ 6 - 1
client/components/common/nav-header.vue

@@ -103,7 +103,7 @@
         v-list-tile(href='/p')
           v-list-tile-action: v-icon(color='red') person
           v-list-tile-title Profile
-        v-list-tile(href='/logout')
+        v-list-tile(@click='logout')
           v-list-tile-action: v-icon(color='red') exit_to_app
           v-list-tile-title Logout
 </template>
@@ -111,6 +111,7 @@
 <script>
 import { get } from 'vuex-pathify'
 import _ from 'lodash'
+import Cookies from 'js-cookie'
 
 export default {
   props: {
@@ -169,6 +170,10 @@ export default {
     },
     pageDelete () {
 
+    },
+    logout () {
+      Cookies.remove('jwt')
+      window.location.assign('/')
     }
   }
 }

+ 153 - 298
client/components/login.vue

@@ -1,47 +1,114 @@
 <template lang="pug">
   v-app
     .login
-      .login-container(:class='{ "is-expanded": strategies.length > 1, "is-loading": isLoading }')
-        .login-mascot
-          img(src='/svg/henry-reading.svg', alt='Henry')
-        .login-providers(v-show='strategies.length > 1')
-          button(v-for='strategy in strategies', :class='{ "is-active": strategy.key === selectedStrategy }', @click='selectStrategy(strategy.key, strategy.useForm)', :title='strategy.title')
-            em(v-html='strategy.icon')
-            span {{ strategy.title }}
-          .login-providers-fill
-        .login-frame(v-show='screen === "login"')
-          h1.text-xs-center.display-1 {{ siteTitle }}
-          h2.text-xs-center.subheading {{ $t('auth:loginRequired') }}
-          v-text-field(solo, hide-details, ref='iptEmail', v-model='username', :placeholder='$t("auth:fields.emailUser")')
-          v-text-field.mt-2(
-            solo
-            hide-details
-            ref='iptPassword'
-            v-model='password'
-            :append-icon='hidePassword ? "visibility" : "visibility_off"'
-            @click:append='() => (hidePassword = !hidePassword)'
-            :type='hidePassword ? "password" : "text"'
-            :placeholder='$t("auth:fields.password")'
-            @keyup.enter='login'
-          )
-          v-btn.mt-3(block, large, color='primary', @click='login') {{ $t('auth:actions.login') }}
-        .login-frame(v-show='screen === "tfa"')
-          .login-frame-icon
-            svg.icons.is-48(role='img')
-              title {{ $t('auth:tfa.title') }}
-              use(xlink:href='#nc-key')
-          h2 {{ $t('auth:tfa.subtitle') }}
-          input(type='text', ref='iptTFA', v-model='securityCode', :placeholder='$t("auth:tfa.placeholder")', @keyup.enter='verifySecurityCode')
-          button.button.is-blue.is-fullwidth(@click='verifySecurityCode')
-            span {{ $t('auth:tfa.verifyToken') }}
-    nav-footer(altbg)
+      v-container(grid-list-lg)
+        v-layout(row, wrap)
+          v-flex(
+            xs12
+            offset-sm1, sm10
+            offset-md2, md8
+            offset-lg3, lg6
+            offset-xl4, xl4
+            )
+            transition(name='zoom')
+              v-card.elevation-5.radius-7(v-show='isShown')
+                v-toolbar(color='primary', flat, dense, dark)
+                  v-spacer
+                  .subheading(v-if='screen === "tfa"') {{ $t('auth:tfa.subtitle') }}
+                  .subheading(v-else-if='selectedStrategy.key !== "local"') Login using {{ selectedStrategy.title }}
+                  .subheading(v-else) {{ $t('auth:loginRequired') }}
+                  v-spacer
+                v-card-text.text-xs-center
+                  h1.display-1.primary--text.py-2 {{ siteTitle }}
+                  template(v-if='screen === "login"')
+                    v-text-field.md2.mt-3(
+                      solo
+                      flat
+                      prepend-icon='email'
+                      background-color='grey lighten-4'
+                      hide-details
+                      ref='iptEmail'
+                      v-model='username'
+                      :placeholder='$t("auth:fields.emailUser")'
+                      )
+                    v-text-field.md2.mt-2(
+                      solo
+                      flat
+                      prepend-icon='vpn_key'
+                      background-color='grey lighten-4'
+                      hide-details
+                      ref='iptPassword'
+                      v-model='password'
+                      :append-icon='hidePassword ? "visibility" : "visibility_off"'
+                      @click:append='() => (hidePassword = !hidePassword)'
+                      :type='hidePassword ? "password" : "text"'
+                      :placeholder='$t("auth:fields.password")'
+                      @keyup.enter='login'
+                    )
+                  template(v-if='screen === "tfa"')
+                    .body-2 Enter the security code generated from your trusted device:
+                    v-text-field.md2.centered.mt-2(
+                      solo
+                      flat
+                      background-color='grey lighten-4'
+                      hide-details
+                      ref='iptTFA'
+                      v-model='securityCode'
+                      :placeholder='$t("auth:tfa.placeholder")'
+                      @keyup.enter='verifySecurityCode'
+                    )
+                v-card-actions.pb-4
+                  v-spacer
+                  v-btn(
+                    v-if='screen === "login"'
+                    block
+                    large
+                    color='primary'
+                    @click='login'
+                    round
+                    :loading='isLoading'
+                    ) {{ $t('auth:actions.login') }}
+                  v-btn(
+                    v-if='screen === "tfa"'
+                    block
+                    large
+                    color='primary'
+                    @click='verifySecurityCode'
+                    round
+                    :loading='isLoading'
+                    ) {{ $t('auth:tfa.verifyToken') }}
+                  v-spacer
+                v-card-actions.pb-3(v-if='selectedStrategy.key === "local"')
+                  v-spacer
+                  a.caption(href='') Forgot your password?
+                  v-spacer
+                template(v-if='isSocialShown')
+                  v-divider
+                  v-card-text.grey.lighten-4.text-xs-center
+                    .pb-2.body-2.text-xs-center.grey--text.text--darken-2 or login using...
+                    v-tooltip(top, v-for='strategy in strategies', :key='strategy.key')
+                      .social-login-btn.mr-2(
+                        slot='activator'
+                        v-ripple
+                        v-html='strategy.icon'
+                        :class='strategy.color + " elevation-" + (strategy.key === selectedStrategy.key ? "0" : "4")'
+                        @click='selectStrategy(strategy)'
+                        )
+                      span {{ strategy.title }}
+                template(v-if='selectedStrategy.selfRegistration')
+                  v-divider
+                  v-card-actions.py-3(:class='isSocialShown ? "" : "grey lighten-4"')
+                    v-spacer
+                    .caption Don't have an account yet? #[a.caption(href='') Create an account]
+                    v-spacer
+    nav-footer(color='grey darken-4')
 </template>
 
 <script>
 /* global siteConfig */
 
 import _ from 'lodash'
-import { mapState } from 'vuex'
+import Cookies from 'js-cookie'
 
 import strategiesQuery from 'gql/login/login-query-strategies.gql'
 import loginMutation from 'gql/login/login-mutation-login.gql'
@@ -53,41 +120,50 @@ export default {
     return {
       error: false,
       strategies: [],
-      selectedStrategy: 'local',
+      selectedStrategy: { key: 'local' },
       screen: 'login',
       username: '',
       password: '',
       hidePassword: true,
       securityCode: '',
       loginToken: '',
-      isLoading: false
+      isLoading: false,
+      isShown: false
     }
   },
   computed: {
-    ...mapState(['notification']),
-    notificationState: {
-      get() { return this.notification.isActive },
-      set(newState) { this.$store.commit('updateNotificationState', newState) }
-    },
     siteTitle () {
       return siteConfig.title
+    },
+    isSocialShown () {
+      return this.strategies.length > 1
+    }
+  },
+  watch: {
+    strategies(newValue, oldValue) {
+      this.selectedStrategy = _.find(newValue, ['key', 'local'])
     }
   },
   mounted () {
-    this.$refs.iptEmail.focus()
+    this.isShown = true
+    this.$nextTick(() => {
+      this.$refs.iptEmail.focus()
+    })
   },
   methods: {
     /**
      * SELECT STRATEGY
      */
-    selectStrategy (key, useForm) {
-      this.selectedStrategy = key
+    selectStrategy (strategy) {
+      this.selectedStrategy = strategy
       this.screen = 'login'
-      if (!useForm) {
+      if (!strategy.useForm) {
         this.isLoading = true
-        window.location.assign(this.$helpers.resolvePath('login/' + key))
+        window.location.assign(this.$helpers.resolvePath('login/' + strategy.key))
       } else {
-        this.$refs.iptEmail.focus()
+        this.$nextTick(() => {
+          this.$refs.iptEmail.focus()
+        })
       }
     },
     /**
@@ -116,7 +192,7 @@ export default {
             variables: {
               username: this.username,
               password: this.password,
-              strategy: this.selectedStrategy
+              strategy: this.selectedStrategy.key
             }
           })
           if (_.has(resp, 'data.authentication.login')) {
@@ -135,6 +211,7 @@ export default {
                   style: 'success',
                   icon: 'check'
                 })
+                Cookies.set('jwt', respObj.jwt, { expires: 365 })
                 _.delay(() => {
                   window.location.replace('/') // TEMPORARY - USE RETURNURL
                 }, 1000)
@@ -222,15 +299,12 @@ export default {
 
 <style lang="scss">
   .login {
-    background-color: mc('blue', '800');
+    background-color: mc('grey', '900');
     background-image: url('../static/svg/motif-blocks.svg');
     background-repeat: repeat;
     background-size: 200px;
     width: 100%;
     height: 100%;
-    display: flex;
-    align-items: center;
-    justify-content: center;
     animation: loginBgReveal 20s linear infinite;
 
     @include keyframes(loginBgReveal) {
@@ -245,7 +319,6 @@ export default {
     &::before {
       content: '';
       position: absolute;
-      background-color: #0d47a1;
       background-image: url('../static/svg/motif-overlay.svg');
       background-attachment: fixed;
       background-size: cover;
@@ -256,259 +329,41 @@ export default {
       height: 100vh;
     }
 
-    &::after {
-      content: '';
-      position: absolute;
-      background-image: linear-gradient(to bottom, rgba(mc('blue', '800'), .9) 0%, rgba(mc('blue', '800'), 0) 100%);
-      top: 0;
-      left: 0;
-      width: 100vw;
-      height: 25vh;
-      z-index: 1;
-    }
-
-    &-mascot {
-      width: 200px;
-      height: 200px;
-      position: absolute;
-      top: -180px;
-      left: 50%;
-      margin-left: -100px;
-      z-index: 10;
-
-      @include until($tablet) {
-        display: none;
-      }
-    }
-
-    &-container {
-      position: relative;
+    > .container {
+      height: 100%;
+      align-items: center;
       display: flex;
-      width: 400px;
-      align-items: stretch;
-      box-shadow: 0 14px 28px rgba(0,0,0,0.2);
-      border-radius: 6px;
-      animation: zoomIn .5s ease;
-      z-index: 2;
-
-      &::after {
-        position: absolute;
-        top: 1rem;
-        right: 1rem;
-        content: " ";
-        @include spinner(mc('blue', '500'),0.5s,16px);
-        display: none;
-      }
-
-      &.is-expanded {
-        width: 650px;
-
-        .login-frame {
-          border-radius: 0 6px 6px 0;
-          border-left: none;
-
-          @include until($tablet) {
-            border-radius: 0;
-          }
-        }
-      }
-
-      &.is-loading::after {
-        display: block;
-      }
-
-      @include until($tablet) {
-        width: 95vw;
-        border-radius: 0;
-
-        &.is-expanded {
-          width: 95vw;
-        }
-      }
     }
 
-    &-providers {
-      display: flex;
-      flex-direction: column;
-      width: 250px;
-
-      border-right: none;
-      border-radius: 6px 0 0 6px;
-      z-index: 1;
-      overflow: hidden;
-
-      @include until($tablet) {
-        width: 50px;
-        border-radius: 0;
-      }
-
-      button {
-        flex: 0 1 50px;
-        padding: 5px 15px;
-        border: none;
-        color: #FFF;
-        // background: linear-gradient(to right, rgba(mc('light-blue', '800'), .7), rgba(mc('light-blue', '800'), 1));
-        // border-top: 1px solid rgba(mc('light-blue', '900'), .5);
-        background: linear-gradient(to right, rgba(0,0,0, .5), rgba(0,0,0, .7));
-        border-top: 1px solid rgba(0,0,0, .2);
-        font-weight: 600;
-        text-align: left;
-        min-height: 40px;
-        display: flex;
-        justify-content: flex-start;
-        align-items: center;
-        transition: all .4s ease;
-
-        &:focus {
-          outline: none;
-        }
-
-        @include until($tablet) {
-          justify-content: center;
-        }
-
-        &:hover {
-          background-color: rgba(0,0,0, .4);
-        }
-
-        &:first-child {
-          border-top: none;
-
-          &.is-active {
-            border-top: 1px solid rgba(255,255,255, .5);
-          }
-        }
-
-        &.is-active {
-          background-image: linear-gradient(to right, rgba(255,255,255,1) 0%,rgba(255,255,255,.77) 100%);
-          color: mc('grey', '800');
-          cursor: default;
-
-          &:hover {
-            background-color: transparent;
-          }
-
-          svg path {
-            fill: mc('grey', '800');
-          }
-        }
-
-        i {
-          margin-right: 10px;
-          font-size: 16px;
-
-          @include until($tablet) {
-            margin-right: 0;
-            font-size: 20px;
-          }
-        }
-
-        svg {
-          margin-right: 10px;
-          width: auto;
-          height: 20px;
-          max-width: 18px;
-          max-height: 20px;
-
-          path {
-            fill: #FFF;
-          }
-
-          @include until($tablet) {
-            margin-right: 0;
-            font-size: 20px;
-          }
-        }
-
-        em {
-          height: 20px;
-        }
-
-        span {
-          font-weight: 600;
-
-          @include until($tablet) {
-            display: none;
-          }
-        }
-      }
-
-      &-fill {
-        flex: 1 1 0;
-        background: linear-gradient(to right, rgba(mc('light-blue', '800'), .7), rgba(mc('light-blue', '800'), 1));
-      }
-    }
-
-    &-frame {
-      background-image: radial-gradient(circle at top center, rgba(255,255,255,1) 5%,rgba(255,255,255,.6) 100%);
-      border: 1px solid rgba(255,255,255, .5);
-      border-radius: 6px;
-      width: 400px;
-      padding: 1rem;
-      color: mc('grey', '700');
-      display: block;
-
-      @include until($tablet) {
-        width: 100%;
-        border-radius: 0;
-        border: none;
-      }
-
-      h1 {
-        font-size: 2rem;
-        font-weight: 400;
-        color: mc('light-blue', '700');
-        text-shadow: 1px 1px 0 #FFF;
-        padding: 1rem 0 0 0;
-        margin: 0;
-      }
-
-      h2 {
-        font-size: 1.5rem;
-        font-weight: 300;
-        color: mc('grey', '700');
-        text-shadow: 1px 1px 0 #FFF;
-        padding: 0;
-        margin: 0 0 25px 0;
-      }
-    }
-
-    &-tfa {
-      position: relative;
-      display: flex;
-      width: 400px;
-      align-items: stretch;
-      box-shadow: 0 14px 28px rgba(0,0,0,0.2);
-      border-radius: 6px;
-      animation: zoomIn .5s ease;
+    h1 {
+      font-family: 'Varela Round' !important;
     }
 
-    &-copyright {
-      display: flex;
-      align-items: center;
+    .social-login-btn {
+      display: inline-flex;
       justify-content: center;
-      position: absolute;
-      left: 0;
-      bottom: 10vh;
-      width: 100%;
-      z-index: 2;
-      color: mc('grey', '500');
-      font-weight: 400;
-
-      a {
-        font-weight: 600;
-        color: mc('blue', '500');
-        margin-left: .25rem;
-
-        @include until($tablet) {
-          color: mc('blue', '200');
-        }
+      align-items: center;
+      border-radius: 50%;
+      width: 54px;
+      height: 54px;
+      cursor: pointer;
+      transition: opacity .2s ease;
+      &:hover {
+        opacity: .8;
       }
-
-      @include until($tablet) {
-        color: mc('blue', '50');
+      margin: .5rem 0;
+      svg {
+        width: 24px;
+        height: 24px;
+        bottom: 0;
+        path {
+          fill: #FFF;
+        }
       }
+    }
 
+    .v-text-field.centered input {
+      text-align: center;
     }
   }
 </style>

+ 1 - 1
client/graph/admin/auth/auth-query-strategies.gql

@@ -1,6 +1,6 @@
 query {
   authentication {
-    strategies(orderBy: "title ASC") {
+    strategies {
       isEnabled
       key
       title

+ 1 - 0
client/graph/login/login-mutation-login.gql

@@ -7,6 +7,7 @@ mutation($username: String!, $password: String!, $strategy: String!) {
         slug
         message
       }
+      jwt
       tfaRequired
       tfaLoginToken
     }

+ 3 - 2
client/graph/login/login-query-strategies.gql

@@ -1,13 +1,14 @@
 query {
   authentication {
     strategies(
-      filter: "isEnabled eq true",
-      orderBy: "title ASC"
+      isEnabled: true
     ) {
       key
       title
       useForm
       icon
+      color
+      selfRegistration
     }
   }
 }

+ 3 - 0
client/scss/app.scss

@@ -4,6 +4,7 @@
 @import "base/icons";
 
 @import "../libs/animate/animate";
+@import '~vue2-animate/src/sass/vue2-animate';
 
 @import 'components/markdown-content';
 @import 'components/v-btn';
@@ -11,6 +12,8 @@
 @import 'components/v-dialog';
 @import 'components/vue-tree-navigation';
 
+@import 'layout/md2';
+
 // @import '../libs/twemoji/twemoji-awesome';
 @import '../libs/prism/prism.css';
 @import '~vue-tour/dist/vue-tour.css';

+ 7 - 0
client/scss/base/base.scss

@@ -18,3 +18,10 @@ html {
     height: 100vh;
   }
 }
+
+
+@for $i from 1 through 25 {
+  .radius-#{$i} {
+    border-radius: #{$i}px;
+  }
+}

+ 27 - 0
client/scss/base/material.scss

@@ -335,3 +335,30 @@ $material-colors: (
 @function mc($color-name, $color-variant: '500') {
 	@return material-color($color-name, $color-variant);
 }
+
+/**
+ * Material Elevation
+ */
+@mixin md-elevation-0 {
+  box-shadow: none !important;
+}
+
+@mixin md-elevation-1 {
+  box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2) !important;
+}
+
+@mixin md-elevation-2 {
+  box-shadow: 0 4px 5px 0 rgba(0, 0, 0, 0.14), 0 1px 10px 0 rgba(0, 0, 0, 0.12), 0 2px 4px -1px rgba(0, 0, 0, 0.3) !important;
+}
+
+@mixin md-elevation-3 {
+  box-shadow: 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12), 0 3px 5px -1px rgba(0, 0, 0, 0.3) !important;
+}
+
+@mixin md-elevation-4 {
+  box-shadow: 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12), 0 5px 5px -3px rgba(0, 0, 0, 0.3) !important;
+}
+
+@mixin md-elevation-5 {
+  box-shadow: 0 16px 24px 2px rgba(0, 0, 0, 0.14), 0 6px 30px 5px rgba(0, 0, 0, 0.12), 0 8px 10px -5px rgba(0, 0, 0, 0.3) !important;
+}

+ 7 - 0
client/scss/layout/_md2.scss

@@ -0,0 +1,7 @@
+.md2 {
+
+  &.v-text-field .v-input__slot {
+    border-radius: 28px;
+  }
+
+}

+ 5 - 1
client/static/svg/auth-icon-auth0.svg

@@ -1 +1,5 @@
-<svg width="2230" height="2500" viewBox="0 0 256 287" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet"><path d="M203.24 231.531l-28.73-88.434 75.208-54.64h-92.966L128.019.025l-.009-.024h92.98l28.74 88.446.002-.002.024-.013c16.69 51.31-.5 109.67-46.516 143.098zm-150.45 0l-.023.017 75.228 54.655 75.245-54.67-75.221-54.656-75.228 54.654zM6.295 88.434c-17.57 54.088 2.825 111.4 46.481 143.108l.007-.028 28.735-88.429-75.192-54.63h92.944L128.004.024 128.01 0H35.025L6.294 88.434z" fill="#EB5424"/></svg>
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg width="100%" height="100%" viewBox="0 0 2230 2500" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
+    <path d="M1770.4,2016.82l-250.262,-770.331l655.122,-475.959l-809.808,0l-250.287,-770.313l-0.079,-0.209l809.93,0l250.349,770.435l0.017,-0.017l0.209,-0.113c145.384,446.951 -4.355,955.313 -405.191,1246.5l0,0.009Zm-1310.54,0l-0.201,0.148l655.296,476.088l655.445,-476.219l-655.235,-476.098l-655.297,476.081l-0.008,0Zm-405.009,-1246.49c-153.049,471.15 24.608,970.383 404.887,1246.59l0.061,-0.244l250.305,-770.287l-654.983,-475.871l809.617,0l250.296,-770.305l0.052,-0.209l-809.974,0l-250.27,770.331l0.009,0Z" style="fill:#fff;fill-rule:nonzero;"/>
+</svg>

+ 6 - 0
client/static/svg/auth-icon-cas.svg

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg width="100%" height="100%" viewBox="0 0 60 74" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
+    <path d="M21.292,54.324c0.332,0.67 0.402,0.971 0.786,1.564c2.846,4.39 6.612,6.586 11.297,6.586c3.749,0 6.942,-1.476 9.58,-4.426c2.637,-2.95 4.13,-6.473 4.477,-10.568l11.87,1.301c-0.764,7.115 -3.514,12.972 -8.252,17.571c-4.737,4.599 -10.959,6.898 -18.664,6.898c-6.49,0 -11.757,-1.631 -15.8,-4.894c-2.868,-2.313 -5.268,-5.211 -7.201,-8.694c3.018,-1.229 7.139,-2.71 11.907,-5.338Zm-14.486,-0.403c-1.897,-5.277 -2.845,-11.051 -2.845,-17.322c0,-10.829 2.568,-19.636 7.705,-26.421c5.137,-6.785 12.199,-10.178 21.189,-10.178c7.045,0 12.911,2.169 17.596,6.508c4.686,4.338 7.636,10.551 8.851,18.638l-10.262,3.552c-2.187,-10.725 -8.619,-18.39 -16.081,-18.39c-4.79,0 -8.512,2.23 -11.167,6.69c-2.655,4.46 -3.983,10.924 -3.983,19.393c0,3.518 0.114,6.087 0.59,8.9c-5.15,2.938 -8.825,6.276 -11.593,8.63Z" style="fill:#fff;fill-rule:nonzero;"/>
+    <path d="M0,61.855c0,-0.579 14.243,-12.318 30.088,-21.125c13.64,-7.581 29.214,-12.496 29.214,-11.997c0,0.474 -11.713,8.753 -24.578,15.996c-16.362,9.21 -34.724,17.729 -34.724,17.126Z" style="fill:#fff;"/>
+</svg>

+ 1 - 0
client/static/svg/auth-icon-oidc.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M8 9v-3c0-2.206 1.794-4 4-4s4 1.794 4 4v3h2v-3c0-3.313-2.687-6-6-6s-6 2.687-6 6v3h2zm.746 2h2.831l-8.577 8.787v-2.9l5.746-5.887zm12.254 1.562v-1.562h-1.37l-12.69 13h2.894l11.166-11.438zm-6.844-1.562l-11.156 11.431v1.569h1.361l12.689-13h-2.894zm6.844 7.13v-2.927l-8.586 8.797h2.858l5.728-5.87zm-3.149 5.87h3.149v-3.226l-3.149 3.226zm-11.685-13h-3.166v3.244l3.166-3.244z"/></svg>

+ 5 - 0
client/static/svg/auth-icon-okta.svg

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg width="100%" height="100%" viewBox="0 0 3840 3840" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
+    <path d="M1920,0c1059.68,0 1920,860.323 1920,1920c0,1059.68 -860.323,1920 -1920,1920c-1059.68,0 -1920,-860.323 -1920,-1920c0,-1059.68 860.323,-1920 1920,-1920Zm0,960c529.838,0 960,430.162 960,960c0,529.838 -430.162,960 -960,960c-529.838,0 -960,-430.162 -960,-960c0,-529.838 430.162,-960 960,-960Z" style="fill:#fff;"/>
+</svg>

+ 5 - 0
client/static/svg/auth-icon-saml.svg

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg width="100%" height="100%" viewBox="0 0 71 74" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
+    <path id="path0" d="M17.853,0.124c-0.103,0.067 -1.003,0.38 -1.999,0.696c-10.672,3.376 -16.206,11.122 -15.837,22.164c0.321,9.583 4.693,16.927 11.683,19.623c2.1,0.809 0.956,0.764 19.409,0.766c14.486,0.002 16.916,0.028 17.532,0.188c2.81,0.732 4.406,2.841 4.707,6.22c0.441,4.964 -1.581,8.399 -5.726,9.727l-1.171,0.375l-17.33,0.124c-9.532,0.067 -19.177,0.136 -21.435,0.152l-4.105,0.028l-0.239,0.295c-0.228,0.282 -0.238,0.579 -0.238,6.532l0,6.236l26.374,0c17.135,0 26.375,-0.043 26.375,-0.123c0,-0.067 0.24,-0.242 0.531,-0.39c8.882,-4.471 13.65,-12.414 13.648,-22.737c-0.001,-9.581 -4.31,-16.351 -12.241,-19.233l-1.25,-0.453l-17.688,-0.063c-16.589,-0.06 -17.722,-0.077 -18.246,-0.286c-2.673,-1.067 -4.032,-3.356 -4.21,-7.09c-0.223,-4.676 1.706,-7.536 6.183,-9.166l1.086,-0.396l21,-0.032l21,-0.031l0.219,-0.264c0.329,-0.397 0.336,-11.475 0.007,-12.392l-0.213,-0.594l-23.819,0.002c-15.145,0.001 -23.888,0.046 -24.007,0.123" style="fill:#fff;"/>
+</svg>

+ 2 - 15
client/static/svg/motif-overlay.svg

@@ -1,15 +1,2 @@
-<!-- background by SVGBackgrounds.com -->
-<svg xmlns='http://www.w3.org/2000/svg' width='100%' height='100%' viewBox='0 0 1600 800'>
-  <g>
-    <path fill='#1356b1' d='M486 705.8c-109.3-21.8-223.4-32.2-335.3-19.4C99.5 692.1 49 703 0 719.8V800h843.8c-115.9-33.2-230.8-68.1-347.6-92.2C492.8 707.1 489.4 706.5 486 705.8z'/>
-    <path fill='#1866c1' d='M1600 0H0v719.8c49-16.8 99.5-27.8 150.7-33.5c111.9-12.7 226-2.4 335.3 19.4c3.4 0.7 6.8 1.4 10.2 2c116.8 24 231.7 59 347.6 92.2H1600V0z'/>
-    <path fill='#1c75d2' d='M478.4 581c3.2 0.8 6.4 1.7 9.5 2.5c196.2 52.5 388.7 133.5 593.5 176.6c174.2 36.6 349.5 29.2 518.6-10.2V0H0v574.9c52.3-17.6 106.5-27.7 161.1-30.9C268.4 537.4 375.7 554.2 478.4 581z'/>
-    <path fill='#1f86e2' d='M0 0v429.4c55.6-18.4 113.5-27.3 171.4-27.7c102.8-0.8 203.2 22.7 299.3 54.5c3 1 5.9 2 8.9 3c183.6 62 365.7 146.1 562.4 192.1c186.7 43.7 376.3 34.4 557.9-12.6V0H0z'/>
-    <path fill='#2196f3' d='M181.8 259.4c98.2 6 191.9 35.2 281.3 72.1c2.8 1.1 5.5 2.3 8.3 3.4c171 71.6 342.7 158.5 531.3 207.7c198.8 51.8 403.4 40.8 597.3-14.8V0H0v283.2C59 263.6 120.6 255.7 181.8 259.4z'/>
-    <path fill='#55a4f5' d='M1600 0H0v136.3c62.3-20.9 127.7-27.5 192.2-19.2c93.6 12.1 180.5 47.7 263.3 89.6c2.6 1.3 5.1 2.6 7.7 3.9c158.4 81.1 319.7 170.9 500.3 223.2c210.5 61 430.8 49 636.6-16.6V0z'/>
-    <path fill='#74b2f7' d='M454.9 86.3C600.7 177 751.6 269.3 924.1 325c208.6 67.4 431.3 60.8 637.9-5.3c12.8-4.1 25.4-8.4 38.1-12.9V0H288.1c56 21.3 108.7 50.6 159.7 82C450.2 83.4 452.5 84.9 454.9 86.3z'/>
-    <path fill='#8ec0f8' d='M1600 0H498c118.1 85.8 243.5 164.5 386.8 216.2c191.8 69.2 400 74.7 595 21.1c40.8-11.2 81.1-25.2 120.3-41.7V0z'/>
-    <path fill='#a5cffa' d='M1397.5 154.8c47.2-10.6 93.6-25.3 138.6-43.8c21.7-8.9 43-18.8 63.9-29.5V0H643.4c62.9 41.7 129.7 78.2 202.1 107.4C1020.4 178.1 1214.2 196.1 1397.5 154.8z'/>
-    <path fill='#bbdefb' d='M1315.3 72.4c75.3-12.6 148.9-37.1 216.8-72.4h-723C966.8 71 1144.7 101 1315.3 72.4z'/>
-  </g>
-</svg>
+<!-- background by SVGBackgrounds.com -->
+<svg xmlns='http://www.w3.org/2000/svg' width='100%' height='100%' viewBox='0 0 1600 800'><rect fill='#000000' width='1600' height='800'/><g ><path fill='#0d0d0d' d='M486 705.8c-109.3-21.8-223.4-32.2-335.3-19.4C99.5 692.1 49 703 0 719.8V800h843.8c-115.9-33.2-230.8-68.1-347.6-92.2C492.8 707.1 489.4 706.5 486 705.8z'/><path fill='#171717' d='M1600 0H0v719.8c49-16.8 99.5-27.8 150.7-33.5c111.9-12.7 226-2.4 335.3 19.4c3.4 0.7 6.8 1.4 10.2 2c116.8 24 231.7 59 347.6 92.2H1600V0z'/><path fill='#1e1e1e' d='M478.4 581c3.2 0.8 6.4 1.7 9.5 2.5c196.2 52.5 388.7 133.5 593.5 176.6c174.2 36.6 349.5 29.2 518.6-10.2V0H0v574.9c52.3-17.6 106.5-27.7 161.1-30.9C268.4 537.4 375.7 554.2 478.4 581z'/><path fill='#262626' d='M0 0v429.4c55.6-18.4 113.5-27.3 171.4-27.7c102.8-0.8 203.2 22.7 299.3 54.5c3 1 5.9 2 8.9 3c183.6 62 365.7 146.1 562.4 192.1c186.7 43.7 376.3 34.4 557.9-12.6V0H0z'/><path fill='#2e2e2e' d='M181.8 259.4c98.2 6 191.9 35.2 281.3 72.1c2.8 1.1 5.5 2.3 8.3 3.4c171 71.6 342.7 158.5 531.3 207.7c198.8 51.8 403.4 40.8 597.3-14.8V0H0v283.2C59 263.6 120.6 255.7 181.8 259.4z'/><path fill='#292929' d='M1600 0H0v136.3c62.3-20.9 127.7-27.5 192.2-19.2c93.6 12.1 180.5 47.7 263.3 89.6c2.6 1.3 5.1 2.6 7.7 3.9c158.4 81.1 319.7 170.9 500.3 223.2c210.5 61 430.8 49 636.6-16.6V0z'/><path fill='#252525' d='M454.9 86.3C600.7 177 751.6 269.3 924.1 325c208.6 67.4 431.3 60.8 637.9-5.3c12.8-4.1 25.4-8.4 38.1-12.9V0H288.1c56 21.3 108.7 50.6 159.7 82C450.2 83.4 452.5 84.9 454.9 86.3z'/><path fill='#202020' d='M1600 0H498c118.1 85.8 243.5 164.5 386.8 216.2c191.8 69.2 400 74.7 595 21.1c40.8-11.2 81.1-25.2 120.3-41.7V0z'/><path fill='#1b1b1b' d='M1397.5 154.8c47.2-10.6 93.6-25.3 138.6-43.8c21.7-8.9 43-18.8 63.9-29.5V0H643.4c62.9 41.7 129.7 78.2 202.1 107.4C1020.4 178.1 1214.2 196.1 1397.5 154.8z'/><path fill='#171717' d='M1315.3 72.4c75.3-12.6 148.9-37.1 216.8-72.4h-723C966.8 71 1144.7 101 1315.3 72.4z'/></g></svg>

+ 1 - 1
dev/templates/master.pug

@@ -22,7 +22,7 @@ html
       var siteConfig = !{JSON.stringify({ title: config.title, theme: config.theming.theme, darkMode: config.theming.darkMode, lang: config.lang.code })}
 
     //- CSS
-    link(type='text/css', rel='stylesheet', href='https://fonts.googleapis.com/icon?family=Roboto:400,500,700|Source+Code+Pro:400,700|Material+Icons')
+    link(type='text/css', rel='stylesheet', href='https://fonts.googleapis.com/icon?family=Roboto:400,500,700|Varela+Round|Source+Code+Pro:400,700|Material+Icons')
     link(type='text/css', rel='stylesheet', href='https://cdnjs.cloudflare.com/ajax/libs/material-design-iconic-font/2.2.0/css/material-design-iconic-font.min.css')
     <% for (var index in htmlWebpackPlugin.files.css) { %>
       <% if (htmlWebpackPlugin.files.cssIntegrity) { %>

+ 2 - 0
package.json

@@ -125,6 +125,7 @@
     "passport-facebook": "2.1.1",
     "passport-github2": "0.1.11",
     "passport-google-oauth20": "1.0.0",
+    "passport-jwt": "4.0.0",
     "passport-ldapauth": "2.1.0",
     "passport-local": "1.0.0",
     "passport-oauth2": "1.4.0",
@@ -264,6 +265,7 @@
     "vue-template-compiler": "2.5.17",
     "vue-tour": "1.0.1",
     "vue-tree-navigation": "3.0.1",
+    "vue2-animate": "2.1.0",
     "vuedraggable": "2.16.0",
     "vuetify": "1.2.5",
     "vuex": "3.0.1",

+ 5 - 4
server/controllers/auth.js

@@ -5,6 +5,7 @@ const express = require('express')
 const router = express.Router()
 const ExpressBrute = require('express-brute')
 const ExpressBruteRedisStore = require('express-brute-redis')
+const jwt = require('jsonwebtoken')
 const moment = require('moment')
 const _ = require('lodash')
 
@@ -40,7 +41,7 @@ router.get('/login', function (req, res, next) {
 router.post('/login', bruteforce.prevent, function (req, res, next) {
   new Promise((resolve, reject) => {
     // [1] LOCAL AUTHENTICATION
-    WIKI.auth.passport.authenticate('local', function (err, user, info) {
+    WIKI.auth.passport.authenticate('local', { session: false }, function (err, user, info) {
       if (err) { return reject(err) }
       if (!user) { return reject(new Error('INVALID_LOGIN')) }
       resolve(user)
@@ -49,7 +50,7 @@ router.post('/login', bruteforce.prevent, function (req, res, next) {
     if (_.has(WIKI.config.auth.strategy, 'ldap')) {
       // [2] LDAP AUTHENTICATION
       return new Promise((resolve, reject) => {
-        WIKI.auth.passport.authenticate('ldapauth', function (err, user, info) {
+        WIKI.auth.passport.authenticate('ldapauth', { session: false }, function (err, user, info) {
           if (err) { return reject(err) }
           if (info && info.message) { return reject(new Error(info.message)) }
           if (!user) { return reject(new Error('INVALID_LOGIN')) }
@@ -61,12 +62,12 @@ router.post('/login', bruteforce.prevent, function (req, res, next) {
     }
   }).then((user) => {
     // LOGIN SUCCESS
-    return req.logIn(user, function (err) {
+    return req.logIn(user, { session: false }, function (err) {
       if (err) { return next(err) }
       req.brute.reset(function () {
         return res.redirect('/')
       })
-    }) || true
+    })
   }).catch(err => {
     // LOGIN FAIL
     if (err.message === 'INVALID_LOGIN') {

+ 22 - 22
server/core/auth.js

@@ -1,14 +1,10 @@
 const passport = require('passport')
+const passportJWT = require('passport-jwt')
 const fs = require('fs-extra')
 const _ = require('lodash')
 const path = require('path')
-const NodeCache = require('node-cache')
 
-const userCache = new NodeCache({
-  stdTTL: 10,
-  checkperiod: 600,
-  deleteOnExpire: true
-})
+const securityHelper = require('../helpers/security')
 
 /* global WIKI */
 
@@ -24,22 +20,16 @@ module.exports = {
     })
 
     passport.deserializeUser(function (id, done) {
-      const usr = userCache.get(id)
-      if (usr) {
-        done(null, usr)
-      } else {
-        WIKI.models.users.query().findById(id).then((user) => {
-          if (user) {
-            userCache.set(id, user)
-            done(null, user)
-          } else {
-            done(new Error(WIKI.lang.t('auth:errors:usernotfound')), null)
-          }
-          return true
-        }).catch((err) => {
-          done(err, null)
-        })
-      }
+      WIKI.models.users.query().findById(id).then((user) => {
+        if (user) {
+          done(null, user)
+        } else {
+          done(new Error(WIKI.lang.t('auth:errors:usernotfound')), null)
+        }
+        return true
+      }).catch((err) => {
+        done(err, null)
+      })
     })
 
     return this
@@ -52,6 +42,16 @@ module.exports = {
       _.pull(currentStrategies, 'session')
       _.forEach(currentStrategies, stg => { passport.unuse(stg) })
 
+      // Load JWT
+      passport.use('jwt', new passportJWT.Strategy({
+        jwtFromRequest: securityHelper.extractJWT,
+        secretOrKey: WIKI.config.sessionSecret,
+        audience: 'urn:wiki.js', // TODO: use value from admin
+        issuer: 'urn:wiki.js'
+      }, (jwtPayload, cb) => {
+        cb(null, jwtPayload)
+      }))
+
       // Load enabled strategies
       const enabledStrategies = await WIKI.models.authentication.getStrategies()
       for (let idx in enabledStrategies) {

+ 18 - 1
server/db/migrations/2.0.0.js

@@ -55,6 +55,8 @@ exports.up = knex => {
       table.charset('utf8mb4')
       table.increments('id').primary()
       table.string('name').notNullable()
+      table.json('permissions').notNullable()
+      table.boolean('isSystem').notNullable().defaultTo(false)
       table.string('createdAt').notNullable()
       table.string('updatedAt').notNullable()
     })
@@ -118,6 +120,17 @@ exports.up = knex => {
       table.string('createdAt').notNullable()
       table.string('updatedAt').notNullable()
     })
+    // PAGE TREE ---------------------------
+    .createTable('pageTree', table => {
+      table.charset('utf8mb4')
+      table.increments('id').primary()
+      table.string('path').notNullable()
+      table.integer('depth').unsigned().notNullable()
+      table.string('title').notNullable()
+      table.boolean('isPrivate').notNullable().defaultTo(false)
+      table.boolean('isFolder').notNullable().defaultTo(false)
+      table.string('privateNS')
+    })
     // RENDERERS ---------------------------
     .createTable('renderers', table => {
       table.charset('utf8mb4')
@@ -166,7 +179,6 @@ exports.up = knex => {
       table.string('password')
       table.boolean('tfaIsActive').notNullable().defaultTo(false)
       table.string('tfaSecret')
-      table.enum('role', ['admin', 'guest', 'user']).notNullable().defaultTo('guest')
       table.string('jobTitle').defaultTo('')
       table.string('location').defaultTo('')
       table.string('pictureUrl')
@@ -221,6 +233,11 @@ exports.up = knex => {
       table.integer('authorId').unsigned().references('id').inTable('users')
       table.integer('creatorId').unsigned().references('id').inTable('users')
     })
+    .table('pageTree', table => {
+      table.integer('parent').unsigned().references('id').inTable('pageTree')
+      table.integer('pageId').unsigned().references('id').inTable('pages')
+      table.string('localeCode', 2).references('code').inTable('locales')
+    })
     .table('users', table => {
       table.string('providerKey').references('key').inTable('authentication').notNullable().defaultTo('local')
       table.string('localeCode', 2).references('code').inTable('locales').notNullable().defaultTo('en')

+ 54 - 0
server/graph/directives/auth.js

@@ -0,0 +1,54 @@
+const { SchemaDirectiveVisitor } = require('graphql-tools')
+const { defaultFieldResolver } = require('graphql')
+
+class AuthDirective extends SchemaDirectiveVisitor {
+  visitObject(type) {
+    this.ensureFieldsWrapped(type)
+    type._requiredAuthScopes = this.args.requires
+  }
+  // Visitor methods for nested types like fields and arguments
+  // also receive a details object that provides information about
+  // the parent and grandparent types.
+  visitFieldDefinition(field, details) {
+    this.ensureFieldsWrapped(details.objectType)
+    field._requiredAuthScopes = this.args.requires
+  }
+
+  visitArgumentDefinition(argument, details) {
+    this.ensureFieldsWrapped(details.objectType)
+    argument._requiredAuthScopes = this.args.requires
+  }
+
+  ensureFieldsWrapped(objectType) {
+    // Mark the GraphQLObjectType object to avoid re-wrapping:
+    if (objectType._authFieldsWrapped) return
+    objectType._authFieldsWrapped = true
+
+    const fields = objectType.getFields()
+
+    Object.keys(fields).forEach(fieldName => {
+      const field = fields[fieldName]
+      const { resolve = defaultFieldResolver } = field
+      field.resolve = async function (...args) {
+        // Get the required scopes from the field first, falling back
+        // to the objectType if no scopes is required by the field:
+        const requiredScopes = field._requiredAuthScopes || objectType._requiredAuthScopes
+
+        if (!requiredScopes) {
+          return resolve.apply(this, args)
+        }
+
+        const context = args[2]
+        console.info(context.req.user)
+        // const user = await getUser(context.headers.authToken)
+        // if (!user.hasRole(requiredScopes)) {
+        //   throw new Error('not authorized')
+        // }
+
+        return resolve.apply(this, args)
+      }
+    })
+  }
+}
+
+module.exports = AuthDirective

+ 6 - 1
server/graph/index.js

@@ -31,6 +31,10 @@ resolversObj.forEach(resolver => {
   _.merge(resolvers, resolver)
 })
 
+// Directives
+
+let schemaDirectives = autoload(path.join(WIKI.SERVERPATH, 'graph/directives'))
+
 // Live Trail Logger (admin)
 
 let LiveTrailLogger = winston.transports.LiveTrailLogger = function (options) {
@@ -55,5 +59,6 @@ WIKI.logger.info(`GraphQL Schema: [ OK ]`)
 
 module.exports = {
   typeDefs,
-  resolvers
+  resolvers,
+  schemaDirectives
 }

+ 1 - 5
server/graph/resolvers/authentication.js

@@ -3,8 +3,6 @@ const fs = require('fs-extra')
 const path = require('path')
 const graphHelper = require('../../helpers/graph')
 
-// const getFieldNames = require('graphql-list-fields')
-
 /* global WIKI */
 
 module.exports = {
@@ -16,7 +14,7 @@ module.exports = {
   },
   AuthenticationQuery: {
     async strategies(obj, args, context, info) {
-      let strategies = await WIKI.models.authentication.getStrategies()
+      let strategies = await WIKI.models.authentication.getStrategies(args.isEnabled)
       strategies = strategies.map(stg => {
         const strategyInfo = _.find(WIKI.data.authentication, ['key', stg.key]) || {}
         return {
@@ -34,8 +32,6 @@ module.exports = {
           }, []), 'key')
         }
       })
-      if (args.filter) { strategies = graphHelper.filter(strategies, args.filter) }
-      if (args.orderBy) { strategies = graphHelper.orderBy(strategies, args.orderBy) }
       return strategies
     }
   },

+ 6 - 5
server/graph/schemas/authentication.graphql

@@ -16,8 +16,7 @@ extend type Mutation {
 
 type AuthenticationQuery {
   strategies(
-    filter: String
-    orderBy: String
+    isEnabled: Boolean
   ): [AuthenticationStrategy]
 }
 
@@ -54,16 +53,18 @@ type AuthenticationStrategy {
   description: String
   useForm: Boolean!
   logo: String
+  color: String
   website: String
   icon: String
-  config: [KeyValuePair]
+  config: [KeyValuePair] @auth(requires: ["manage:system"])
   selfRegistration: Boolean!
-  domainWhitelist: [String]!
-  autoEnrollGroups: [Int]!
+  domainWhitelist: [String]! @auth(requires: ["manage:system"])
+  autoEnrollGroups: [Int]! @auth(requires: ["manage:system"])
 }
 
 type AuthenticationLoginResponse {
   responseResult: ResponseStatus
+  jwt: String
   tfaRequired: Boolean
   tfaLoginToken: String
 }

+ 4 - 0
server/graph/schemas/common.graphql

@@ -13,6 +13,10 @@ enum RightRole {
   manage
 }
 
+# DIRECTIVES
+
+directive @auth(requires: [String]) on QUERY | FIELD_DEFINITION | ARGUMENT_DEFINITION
+
 # TYPES
 
 type KeyValuePair {

+ 14 - 0
server/helpers/security.js

@@ -1,5 +1,6 @@
 const Promise = require('bluebird')
 const crypto = require('crypto')
+const passportJWT = require('passport-jwt')
 
 module.exports = {
   sanitizeCommitUser (user) {
@@ -21,5 +22,18 @@ module.exports = {
     }).then(buf => {
       return buf.toString('hex')
     })
+  },
+
+  async extractJWT (req) {
+    return passportJWT.ExtractJwt.fromExtractors([
+      passportJWT.ExtractJwt.fromAuthHeaderAsBearerToken(),
+      (req) => {
+        let token = null
+        if (req && req.cookies) {
+          token = req.cookies['jwt']
+        }
+        return token
+      }
+    ])(req)
   }
 }

+ 2 - 15
server/master.js

@@ -7,8 +7,6 @@ const express = require('express')
 const favicon = require('serve-favicon')
 const http = require('http')
 const path = require('path')
-const session = require('express-session')
-const SessionRedisStore = require('connect-redis')(session)
 const { ApolloServer } = require('apollo-server-express')
 // const oauth2orize = require('oauth2orize')
 
@@ -66,20 +64,9 @@ module.exports = async () => {
   // Passport Authentication
   // ----------------------------------------
 
-  let sessionStore = new SessionRedisStore({
-    client: WIKI.redis
-  })
-
   app.use(cookieParser())
-  app.use(session({
-    name: 'wikijs.sid',
-    store: sessionStore,
-    secret: WIKI.config.sessionSecret,
-    resave: false,
-    saveUninitialized: false
-  }))
   app.use(WIKI.auth.passport.initialize())
-  app.use(WIKI.auth.passport.session())
+  app.use(mw.auth.jwt)
 
   // ----------------------------------------
   // SEO
@@ -145,7 +132,7 @@ module.exports = async () => {
 
   app.use('/', ctrl.auth)
 
-  app.use('/', mw.auth, ctrl.common)
+  app.use('/', mw.auth.checkPath, ctrl.common)
 
   // ----------------------------------------
   // Error handling

+ 60 - 18
server/middlewares/auth.js

@@ -1,33 +1,75 @@
+const jwt = require('jsonwebtoken')
+const moment = require('moment')
+
+const securityHelper = require('../helpers/security')
+
 /* global WIKI */
 
 /**
  * Authentication middleware
  */
-module.exports = (req, res, next) => {
-  // Is user authenticated ?
+module.exports = {
+  jwt(req, res, next) {
+    WIKI.auth.passport.authenticate('jwt', {session: false}, async (err, user, info) => {
+      if (err) { return next() }
+
+      console.info(err, user, info)
+
+      // Expired but still valid within 7 days, just renew
+      if (info instanceof jwt.TokenExpiredError && moment().subtract(7, 'days').isBefore(info.expiredAt)) {
+        const jwtPayload = jwt.decode(securityHelper.extractJWT(req))
+        console.info(jwtPayload)
+        try {
+          const newToken = await WIKI.models.users.refreshToken(jwtPayload.id)
+          user = newToken.user
+
+          // Try headers, otherwise cookies for response
+          if (req.get('content-type') === 'application/json') {
+            res.headers('new-jwt', newToken.token)
+          } else {
+            res.cookie('jwt', newToken.token, { expires: moment().add(7, 'days').toDate() })
+          }
+        } catch (err) {
+          return next()
+        }
+      }
 
-  if (!req.isAuthenticated()) {
-    if (WIKI.config.public !== true) {
-      return res.redirect('/login')
+      // JWT is NOT valid
+      if (!user) { return next() }
+
+      // JWT is valid
+      req.logIn(user, { session: false }, (err) => {
+        if (err) { return next(err) }
+        next()
+      })
+    })(req, res, next)
+  },
+  checkPath(req, res, next) {
+    // Is user authenticated ?
+
+    if (!req.isAuthenticated()) {
+      if (WIKI.config.public !== true) {
+        return res.redirect('/login')
+      } else {
+        // req.user = rights.guest
+        res.locals.isGuest = true
+      }
     } else {
-      // req.user = rights.guest
-      res.locals.isGuest = true
+      res.locals.isGuest = false
     }
-  } else {
-    res.locals.isGuest = false
-  }
 
-  // Check permissions
+    // Check permissions
 
-  // res.locals.rights = rights.check(req)
+    // res.locals.rights = rights.check(req)
 
-  // if (!res.locals.rights.read) {
-  //   return res.render('error-forbidden')
-  // }
+    // if (!res.locals.rights.read) {
+    //   return res.render('error-forbidden')
+    // }
 
-  // Expose user data
+    // Expose user data
 
-  res.locals.user = req.user
+    res.locals.user = req.user
 
-  return next()
+    return next()
+  }
 }

+ 4 - 4
server/models/authentication.js

@@ -30,13 +30,13 @@ module.exports = class Authentication extends Model {
     }
   }
 
-  static async getStrategies() {
-    const strategies = await WIKI.models.authentication.query()
-    return strategies.map(str => ({
+  static async getStrategies(isEnabled) {
+    const strategies = await WIKI.models.authentication.query().where(_.isBoolean(isEnabled) ? { isEnabled } : {})
+    return _.sortBy(strategies.map(str => ({
       ...str,
       domainWhitelist: _.get(str.domainWhitelist, 'v', []),
       autoEnrollGroups: _.get(str.autoEnrollGroups, 'v', [])
-    }))
+    })), ['title'])
   }
 
   static async refreshStrategiesFromDisk() {

+ 32 - 2
server/models/users.js

@@ -4,6 +4,7 @@ const bcrypt = require('bcryptjs-then')
 const _ = require('lodash')
 const tfa = require('node-2fa')
 const securityHelper = require('../helpers/security')
+const jwt = require('jsonwebtoken')
 const Model = require('objection').Model
 
 const bcryptRegexp = /^\$2[ayb]\$[0-9]{2}\$[A-Za-z0-9./]{53}$/
@@ -199,7 +200,7 @@ module.exports = class User extends Model {
 
       // Authenticate
       return new Promise((resolve, reject) => {
-        WIKI.auth.passport.authenticate(opts.strategy, async (err, user, info) => {
+        WIKI.auth.passport.authenticate(opts.strategy, { session: false }, async (err, user, info) => {
           if (err) { return reject(err) }
           if (!user) { return reject(new WIKI.Error.AuthLoginFailed()) }
 
@@ -218,9 +219,11 @@ module.exports = class User extends Model {
             }
           } else {
             // No 2FA, log in user
-            return context.req.logIn(user, err => {
+            return context.req.logIn(user, { session: false }, async err => {
               if (err) { return reject(err) }
+              const jwtToken = await WIKI.models.users.refreshToken(user)
               resolve({
+                jwt: jwtToken.token,
                 tfaRequired: false
               })
             })
@@ -232,6 +235,33 @@ module.exports = class User extends Model {
     }
   }
 
+  static async refreshToken(user) {
+    if (_.isSafeInteger(user)) {
+      user = await WIKI.models.users.query().findById(user)
+      if (!user) {
+        WIKI.logger.warn(`Failed to refresh token for user ${user}: Not found.`)
+        throw new WIKI.Error.AuthGenericError()
+      }
+    }
+    return {
+      token: jwt.sign({
+        id: user.id,
+        email: user.email,
+        name: user.name,
+        pictureUrl: user.pictureUrl,
+        timezone: user.timezone,
+        localeCode: user.localeCode,
+        defaultEditor: user.defaultEditor,
+        permissions: []
+      }, WIKI.config.sessionSecret, {
+        expiresIn: '10s',
+        audience: 'urn:wiki.js', // TODO: use value from admin
+        issuer: 'urn:wiki.js'
+      }),
+      user
+    }
+  }
+
   static async loginTFA(opts, context) {
     if (opts.securityCode.length === 6 && opts.loginToken.length === 64) {
       let result = await WIKI.redis.get(`tfa:${opts.loginToken}`)

+ 1 - 0
server/modules/authentication/auth0/definition.yml

@@ -3,6 +3,7 @@ title: Auth0
 description: Auth0 provides universal identity platform for web, mobile, IoT, and internal applications.
 author: requarks.io
 logo: https://static.requarks.io/logo/auth0.svg
+color: deep-orange
 website: https://auth0.com/
 useForm: false
 props:

+ 1 - 0
server/modules/authentication/azure/definition.yml

@@ -3,6 +3,7 @@ title: Azure Active Directory
 description: Azure Active Directory (Azure AD) is Microsoft’s multi-tenant, cloud-based directory, and identity management service that combines core directory services, application access management, and identity protection into a single solution.
 author: requarks.io
 logo: https://static.requarks.io/logo/azure.svg
+color: blue darken-3
 website: https://azure.microsoft.com/services/active-directory/
 useForm: false
 props:

+ 1 - 0
server/modules/authentication/cas/definition.yml

@@ -3,6 +3,7 @@ title: CAS
 description: The Central Authentication Service (CAS) is a single sign-on protocol for the web.
 author: requarks.io
 logo: https://static.requarks.io/logo/cas.svg
+color: green darken-2
 website: https://apereo.github.io/cas/
 useForm: false
 props:

+ 1 - 0
server/modules/authentication/discord/definition.yml

@@ -3,6 +3,7 @@ title: Discord
 description: Discord is a proprietary freeware VoIP application designed for gaming communities, that specializes in text, video and audio communication between users in a chat channel.
 author: requarks.io
 logo: https://static.requarks.io/logo/discord.svg
+color: indigo lighten-2
 website: https://discordapp.com/
 useForm: false
 props:

+ 1 - 0
server/modules/authentication/dropbox/definition.yml

@@ -3,6 +3,7 @@ title: Dropbox
 description: Dropbox is a file hosting service that offers cloud storage, file synchronization, personal cloud, and client software.
 author: requarks.io
 logo: https://static.requarks.io/logo/dropbox.svg
+color: blue darken-2
 website: https://dropbox.com
 useForm: false
 props:

+ 1 - 0
server/modules/authentication/facebook/definition.yml

@@ -3,6 +3,7 @@ title: Facebook
 description: Facebook is an online social media and social networking service company.
 author: requarks.io
 logo: https://static.requarks.io/logo/facebook.svg
+color: indigo
 website: https://facebook.com/
 useForm: false
 props:

+ 1 - 0
server/modules/authentication/github/definition.yml

@@ -3,6 +3,7 @@ title: GitHub
 description: GitHub Inc. is a web-based hosting service for version control using Git.
 author: requarks.io
 logo: https://static.requarks.io/logo/github.svg
+color: grey darken-3
 website: https://github.com
 useForm: false
 props:

+ 1 - 0
server/modules/authentication/google/definition.yml

@@ -3,6 +3,7 @@ title: Google
 description: Google specializes in Internet-related services and products, which include online advertising technologies, search engine, cloud computing, software, and hardware.
 author: requarks.io
 logo: https://static.requarks.io/logo/google.svg
+color: red darken-1
 website: https://console.developers.google.com/
 useForm: false
 props:

+ 1 - 0
server/modules/authentication/ldap/definition.yml

@@ -3,6 +3,7 @@ title: LDAP / Active Directory
 description: Active Directory is a directory service that Microsoft developed for the Windows domain networks.
 author: requarks.io
 logo: https://static.requarks.io/logo/active-directory.svg
+color: blue darken-3
 website: https://www.microsoft.com/windowsserver
 useForm: true
 props:

+ 1 - 0
server/modules/authentication/local/definition.yml

@@ -3,6 +3,7 @@ title: Local
 description: Built-in authentication for Wiki.js
 author: requarks.io
 logo: https://static.requarks.io/logo/wikijs.svg
+color: yellow darken-3
 website: https://wiki.js.org
 useForm: true
 props: {}

+ 1 - 0
server/modules/authentication/microsoft/definition.yml

@@ -3,6 +3,7 @@ title: Microsoft
 description: Microsoft is a software company, best known for it's Windows, Office, Azure, Xbox and Surface products.
 author: requarks.io
 logo: https://static.requarks.io/logo/microsoft.svg
+color: blue
 website: https://apps.dev.microsoft.com/
 useForm: false
 props:

+ 1 - 0
server/modules/authentication/oauth2/definition.yml

@@ -3,6 +3,7 @@ title: Generic OAuth2
 description: OAuth 2 is an authorization framework that enables applications to obtain limited access to user accounts on an HTTP service.
 author: requarks.io
 logo: https://static.requarks.io/logo/oauth2.svg
+color: grey darken-4
 website: https://oauth.net/2/
 useForm: false
 props:

+ 1 - 0
server/modules/authentication/oidc/definition.yml

@@ -3,6 +3,7 @@ title: Generic OpenID Connect
 description: OpenID Connect 1.0 is a simple identity layer on top of the OAuth 2.0 protocol.
 author: requarks.io
 logo: https://static.requarks.io/logo/oidc.svg
+color: blue-grey darken-2
 website: http://openid.net/connect/
 useForm: false
 props:

+ 1 - 0
server/modules/authentication/okta/definition.yml

@@ -3,6 +3,7 @@ title: Okta
 description: Okta provide secure identity management and single sign-on to any application.
 author: requarks.io
 logo: https://static.requarks.io/logo/okta.svg
+color: blue darken-1
 website: https://www.okta.com/
 useForm: false
 props:

+ 1 - 0
server/modules/authentication/saml/definition.yml

@@ -3,6 +3,7 @@ title: SAML 2.0
 description: Security Assertion Markup Language 2.0 (SAML 2.0) is a version of the SAML standard for exchanging authentication and authorization data between security domains.
 author: requarks.io
 logo: https://static.requarks.io/logo/saml.svg
+color: red darken-3
 website: https://wiki.oasis-open.org/security/FrontPage
 useForm: false
 props:

+ 1 - 0
server/modules/authentication/slack/definition.yml

@@ -3,6 +3,7 @@ title: Slack
 description: Slack is a cloud-based set of proprietary team collaboration tools and services.
 author: requarks.io
 logo: https://static.requarks.io/logo/slack.svg
+color: green
 website: https://api.slack.com/docs/oauth
 useForm: false
 props:

+ 1 - 0
server/modules/authentication/twitch/definition.yml

@@ -3,6 +3,7 @@ title: Twitch
 description: Twitch is a live streaming video platform.
 author: requarks.io
 logo: https://static.requarks.io/logo/twitch.svg
+color: indigo darken-2
 website: https://dev.twitch.tv/docs/authentication/
 useForm: false
 props:

+ 27 - 16
server/setup.js

@@ -1,5 +1,4 @@
 const path = require('path')
-const os = require('os')
 
 /* global WIKI */
 
@@ -133,6 +132,20 @@ module.exports = () => {
         nativeName: 'English'
       })
 
+      // Create default locale
+
+      WIKI.logger.info('Creating default groups...')
+      const adminGroup = await WIKI.models.groups.query().insert({
+        name: 'Administrators',
+        permissions: JSON.stringify(['manage:system']),
+        isSystem: true
+      })
+      const guestGroup = await WIKI.models.groups.query().insert({
+        name: 'Guests',
+        permissions: JSON.stringify(['read:page:/']),
+        isSystem: true
+      })
+
       // Load authentication strategies + enable local
       await WIKI.models.authentication.refreshStrategiesFromDisk()
       await WIKI.models.authentication.query().patch({ isEnabled: true }).where('key', 'local')
@@ -160,35 +173,33 @@ module.exports = () => {
         providerKey: 'local',
         email: req.body.adminEmail
       })
-      await WIKI.models.users.query().insert({
+      const adminUser = await WIKI.models.users.query().insert({
         email: req.body.adminEmail,
         provider: 'local',
         password: req.body.adminPassword,
         name: 'Administrator',
-        role: 'admin',
         locale: 'en',
         defaultEditor: 'markdown',
         tfaIsActive: false
       })
+      await adminUser.$relatedQuery('groups').relate(adminGroup.id)
 
       // Create Guest account
       WIKI.logger.info('Creating guest account...')
-      const guestUsr = await WIKI.models.users.query().findOne({
+      await WIKI.models.users.query().delete().where({
         providerKey: 'local',
         email: 'guest@example.com'
       })
-      if (!guestUsr) {
-        await WIKI.models.users.query().insert({
-          provider: 'local',
-          email: 'guest@example.com',
-          name: 'Guest',
-          password: '',
-          role: 'guest',
-          locale: 'en',
-          defaultEditor: 'markdown',
-          tfaIsActive: false
-        })
-      }
+      const guestUser = await WIKI.models.users.query().insert({
+        provider: 'local',
+        email: 'guest@example.com',
+        name: 'Guest',
+        password: '',
+        locale: 'en',
+        defaultEditor: 'markdown',
+        tfaIsActive: false
+      })
+      await guestUser.$relatedQuery('groups').relate(guestGroup.id)
 
       WIKI.logger.info('Setup is complete!')
       res.json({

+ 1 - 1
server/views/master.pug

@@ -22,7 +22,7 @@ html
       var siteConfig = !{JSON.stringify({ title: config.title, theme: config.theming.theme, darkMode: config.theming.darkMode, lang: config.lang.code })}
 
     //- CSS
-    link(type='text/css', rel='stylesheet', href='https://fonts.googleapis.com/icon?family=Roboto:400,500,700|Source+Code+Pro:400,700|Material+Icons')
+    link(type='text/css', rel='stylesheet', href='https://fonts.googleapis.com/icon?family=Roboto:400,500,700|Varela+Round|Source+Code+Pro:400,700|Material+Icons')
     link(type='text/css', rel='stylesheet', href='https://cdnjs.cloudflare.com/ajax/libs/material-design-iconic-font/2.2.0/css/material-design-iconic-font.min.css')
     
 

+ 12 - 1
yarn.lock

@@ -6917,7 +6917,7 @@ jsonify@~0.0.0:
   version "0.0.0"
   resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73"
 
-jsonwebtoken@8.3.0:
+jsonwebtoken@8.3.0, jsonwebtoken@^8.2.0:
   version "8.3.0"
   resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.3.0.tgz#056c90eee9a65ed6e6c72ddb0a1d325109aaf643"
   dependencies:
@@ -8856,6 +8856,13 @@ passport-google-oauth20@1.0.0:
   dependencies:
     passport-oauth2 "1.x.x"
 
+passport-jwt@4.0.0:
+  version "4.0.0"
+  resolved "http://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.0.tgz#7f0be7ba942e28b9f5d22c2ebbb8ce96ef7cf065"
+  dependencies:
+    jsonwebtoken "^8.2.0"
+    passport-strategy "^1.0.0"
+
 passport-ldapauth@2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/passport-ldapauth/-/passport-ldapauth-2.1.0.tgz#7d113c6d118504ee76e671b27828c88371b90815"
@@ -12964,6 +12971,10 @@ vue-tree-navigation@3.0.1:
   dependencies:
     vue "^2.5.11"
 
+vue2-animate@2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/vue2-animate/-/vue2-animate-2.1.0.tgz#93eee5f381d8d6493bae0507bcad6b615fdacb7f"
+
 vue@2.5.16, vue@^2.5.13:
   version "2.5.16"
   resolved "https://registry.yarnpkg.com/vue/-/vue-2.5.16.tgz#07edb75e8412aaeed871ebafa99f4672584a0085"