├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── assets ├── .doolrc ├── admin.scss ├── auth.scss ├── common.js ├── common.scss ├── login.js ├── logo.png ├── main.scss ├── reset.js ├── roles.js ├── select.js └── variables.scss ├── bin ├── _config.js ├── cli.js ├── init.js └── start.js ├── examples ├── config1.js ├── example1.js └── example2.js ├── i18n └── zh-CN.json ├── package.json ├── scripts └── server.js ├── src ├── config.js ├── controllers │ ├── admin.js │ ├── scan.js │ └── user.js ├── i18n.js ├── index.js ├── middlewares │ ├── error.js │ ├── flash.js │ ├── log.js │ └── mail.js ├── models │ ├── client.js │ ├── code.js │ ├── dic_role.js │ ├── log.js │ ├── qrcode.js │ ├── recovery.js │ ├── role.js │ ├── token.js │ └── user.js ├── oauth │ ├── errors.js │ └── index.js ├── routes.js └── util.js ├── test ├── client.js ├── config.js ├── index.test.js ├── oauth.test.js ├── util.test.js └── utils.js └── views ├── admin ├── clients.html ├── layout.html ├── roles.html └── users.html ├── auth ├── change.html ├── layout.html ├── login.html └── reset.html ├── error.html └── main ├── home.html ├── layout.html └── security.html /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "targets": { "node": 8 } 5 | }] 6 | ], 7 | "plugins": ["add-module-exports"], 8 | "env": { 9 | "test": { 10 | "plugins": ["istanbul"] 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 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 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | parser: babel-eslint 2 | extends: standard 3 | 4 | env: 5 | node: true 6 | mocha: true 7 | 8 | rules: 9 | camelcase: 0 10 | no-unused-expressions: 0 11 | node/no-deprecated-api: 0 12 | semi: [2, always] 13 | prefer-const: 1 14 | object-curly-spacing: [2, always] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .idea/ 3 | .ipr 4 | .iws 5 | *~ 6 | ~* 7 | *.diff 8 | *.patch 9 | *.bak 10 | .DS_Store 11 | Thumbs.db 12 | .project 13 | .*proj 14 | .svn/ 15 | *.swp 16 | *.swo 17 | *.log 18 | *.sublime-project 19 | *.sublime-workspace 20 | 21 | npm-debug.log 22 | package-lock.json 23 | node_modules 24 | spm_modules 25 | tmp/ 26 | 27 | .buildpath 28 | .settings 29 | coverage 30 | .nyc_output 31 | app/ 32 | public/ 33 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | tmp 4 | spm_modules 5 | node_modules 6 | examples 7 | coverage 8 | test 9 | src/ 10 | assets/ 11 | scripts/ 12 | .nyc_output 13 | .babelrc 14 | .editorconfig 15 | .eslintrc 16 | .travis.yml -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 8 5 | - 10 6 | 7 | after_success: 8 | - npm run coveralls -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Auth Center (OAuth2.0 + TOTP) 2 | === 3 | 4 | [![NPM version](https://img.shields.io/npm/v/auth-center.svg)](https://www.npmjs.com/package/auth-center) 5 | [![NPM downloads](https://img.shields.io/npm/dm/auth-center.svg)](https://www.npmjs.com/package/auth-center) 6 | [![Build Status](https://travis-ci.org/d-band/auth-center.svg?branch=master)](https://travis-ci.org/d-band/auth-center) 7 | [![Coverage Status](https://coveralls.io/repos/github/d-band/auth-center/badge.svg?branch=master)](https://coveralls.io/github/d-band/auth-center?branch=master) 8 | [![Dependency Status](https://david-dm.org/d-band/auth-center.svg)](https://david-dm.org/d-band/auth-center) 9 | 10 | ### 安装 11 | 12 | ``` 13 | // 全局安装 14 | npm i auth-center -g 15 | // 非全局安装 16 | npm i auth-center -S 17 | ``` 18 | 19 | ### 功能列表 20 | 21 | 22 | - 配置方便、简单,UI简洁 23 | - 多数据库支持:MySQL、Postgres、sqlite、mariadb 24 | - session支持redis等 25 | - OAuth2.0 授权码模式 26 | - 密码验证增强(TOTP) 27 | - 自带后台管理 28 | 29 | ### 使用说明 30 | 31 | > 完整配置文件参考:[config.js](./src/config.js) 32 | 33 | #### 1. 采用命令行执行 34 | 35 | ``` 36 | $ auth-center -h 37 | 38 | Usage: auth-center [options] [command] 39 | 40 | 41 | Commands: 42 | 43 | init init config 44 | start [options] start server 45 | 46 | Options: 47 | 48 | -h, --help output usage information 49 | -v, --version output the version number 50 | 51 | $ auth-center init 52 | 53 | $ auth-center start -h 54 | 55 | Usage: auth-center start [options] 56 | 57 | start server 58 | 59 | Options: 60 | 61 | -h, --help output usage information 62 | -p, --port server port 63 | --config custom config path 64 | --sync sync database to generate tables 65 | --data init data with json file 66 | 67 | ``` 68 | 69 | #### 2. 采用引入方式执行 70 | 71 | ``` 72 | const AuthServer = require('auth-center'); 73 | 74 | const server = AuthServer({ 75 | domain: 'http://passport.example.com', 76 | orm: { 77 | database: 'db_auth', 78 | username: 'root', 79 | password: 'xxxx', 80 | dialect: 'mysql', 81 | host: '127.0.0.1', 82 | port: 3306, 83 | pool: { 84 | maxConnections: 10, 85 | minConnections: 0, 86 | maxIdleTime: 30000 87 | } 88 | }, 89 | mail: { 90 | from: '系统管理员 ', 91 | host: 'smtp.example.com', 92 | port: 465, 93 | secure: true, 94 | auth: { 95 | user: 'admin@example.com', 96 | pass: 'admin' 97 | } 98 | } 99 | }); 100 | 101 | server.listen(3000); 102 | 103 | server.orm.database().sync({ 104 | force: true 105 | }).then(() => { console.log('Sync done.'); }); 106 | ``` 107 | 108 | ### 开发 109 | 110 | ``` 111 | git clone https://github.com/d-band/auth-center.git 112 | cd auth-center 113 | 114 | npm install 115 | 116 | npm run dev 117 | npm start 118 | ``` 119 | 120 | ### 参考链接 121 | 122 | - https://github.com/oauthjs/express-oauth-server 123 | - https://github.com/jaredhanson/oauth2orize 124 | - https://tools.ietf.org/html/rfc6749#section-4 125 | - https://tools.ietf.org/html/rfc6750 126 | - http://www.ruanyifeng.com/blog/2014/05/oauth_2_0.html 127 | - https://developer.github.com/v3/oauth/ 128 | - https://github.com/guyht/notp 129 | -------------------------------------------------------------------------------- /assets/.doolrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "./auth.scss", 4 | "./main.scss", 5 | "./admin.scss", 6 | "./common.js", 7 | "./roles.js", 8 | "./login.js", 9 | "./reset.js" 10 | ] 11 | } -------------------------------------------------------------------------------- /assets/admin.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Sidebar 3 | */ 4 | 5 | .sidebar { 6 | position: fixed; 7 | top: 0; 8 | bottom: 0; 9 | left: 0; 10 | z-index: 100; /* Behind the navbar */ 11 | padding: 48px 0 0; /* Height of navbar */ 12 | box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1); 13 | background: #495469; 14 | 15 | .nav-link { 16 | color: rgba(255,255,255,.5); 17 | &.active { 18 | color: #fff; 19 | background-color: #1582dc; 20 | } 21 | &:hover { 22 | color: #fff; 23 | } 24 | } 25 | } 26 | 27 | .sidebar-sticky { 28 | position: relative; 29 | top: 0; 30 | height: calc(100vh - 48px); 31 | padding-top: 1rem; 32 | overflow-x: hidden; 33 | overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */ 34 | } 35 | 36 | @supports ((position:-webkit-sticky) or (position:sticky)) { 37 | .sidebar-sticky { 38 | position: -webkit-sticky; 39 | position: sticky; 40 | } 41 | } 42 | 43 | .sidebar-heading { 44 | font-size: .75rem; 45 | text-transform: uppercase; 46 | } 47 | 48 | /* 49 | * Content 50 | */ 51 | 52 | [role="main"] { 53 | padding-top: 133px; /* Space for fixed navbar */ 54 | } 55 | 56 | @media (min-width: 768px) { 57 | [role="main"] { 58 | padding-top: 48px; /* Space for fixed navbar */ 59 | } 60 | } 61 | 62 | /* 63 | * Navbar 64 | */ 65 | 66 | .navbar-brand { 67 | padding-top: .5rem; 68 | padding-bottom: .5rem; 69 | font-size: 1rem; 70 | img { 71 | height: 2rem; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /assets/auth.scss: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #f9f9f9; 3 | } 4 | .header-logo { 5 | padding: 40px 0 20px; 6 | text-align: center; 7 | } 8 | .header-title { 9 | font-size: 20px; 10 | font-weight: 300; 11 | text-align: center; 12 | color: #333; 13 | margin-bottom: 1rem; 14 | } 15 | 16 | .auth-form { 17 | width: 360px; 18 | margin: 0 auto; 19 | .send-btn { 20 | min-width: 100px; 21 | } 22 | .qrcode-img { 23 | position: relative; 24 | width: 300px; 25 | height: 300px; 26 | .qrcode-error { 27 | background: hsla(0, 0%, 100%, .95); 28 | position: absolute; 29 | left: 0; 30 | top: 0; 31 | z-index: 9999; 32 | width: 100%; 33 | height: 100%; 34 | text-align: center; 35 | p { 36 | margin-top: 90px; 37 | } 38 | } 39 | } 40 | } 41 | 42 | @media (max-width: 360px) { 43 | .auth-form { 44 | width: 100%; 45 | } 46 | } 47 | 48 | .bottom-link { 49 | text-align: center; 50 | } 51 | 52 | .lang { 53 | position: fixed; 54 | bottom: 0; 55 | width: 100%; 56 | padding: 20px; 57 | text-align: center; 58 | span { 59 | margin: 0 8px; 60 | color: #888; 61 | } 62 | } 63 | 64 | @media (max-height: 610px) { 65 | .lang { 66 | position: relative; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /assets/common.js: -------------------------------------------------------------------------------- 1 | import './common.scss'; 2 | 3 | window.$ = window.jQuery = require('jquery'); 4 | require('bootstrap'); 5 | 6 | function cookie(name) { 7 | const match = document.cookie.match(new RegExp('(^|;\\s*)(' + name + ')=([^;]*)')); 8 | return (match ? decodeURIComponent(match[3]) : null); 9 | } 10 | 11 | $.ajaxPrefilter(function(options, originalOptions, jqXHR) { 12 | const key = 'XSRF-TOKEN'; 13 | jqXHR.setRequestHeader(key, cookie(key)); 14 | }); 15 | -------------------------------------------------------------------------------- /assets/common.scss: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/scss/bootstrap"; 2 | @import "variables"; 3 | 4 | html { 5 | text-rendering: optimizeLegibility; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | body { 11 | background-color: #fff; 12 | } 13 | 14 | .btn-default { 15 | @include button-variant(#f4f5f8, #e2e5ec); 16 | } 17 | 18 | .navbar-tabs .nav-item .nav-link { 19 | position: relative; 20 | padding-left: 1rem; 21 | padding-right: 1rem; 22 | 23 | &.active { 24 | color: $dark; 25 | } 26 | &.active:before { 27 | display: block; 28 | content: ''; 29 | width: 100%; 30 | height: 3px; 31 | position: absolute; 32 | left: 0; 33 | bottom: -0.5rem; 34 | background-color: $primary; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /assets/login.js: -------------------------------------------------------------------------------- 1 | import '!file-loader?name=[name].[ext]!./logo.png'; 2 | import QRCode from 'qrcode'; 3 | 4 | $(function() { 5 | const $terms = $('#terms'); 6 | if ($terms.length) { 7 | const handleTerms = () => { 8 | if ($terms.prop('checked')) { 9 | $('#J_submit').attr('disabled', false); 10 | } else { 11 | $('#J_submit').attr('disabled', true); 12 | } 13 | }; 14 | $terms.on('change', handleTerms); 15 | handleTerms(); 16 | } 17 | 18 | $('#J_send').on('click', e => { 19 | const elem = $(e.currentTarget); 20 | elem.attr('disabled', true); 21 | $.post('/login_token', { 22 | email: $('#J_email').val() 23 | }, null, 'json').done(() => { 24 | const text = elem.text(); 25 | let count = 60; 26 | elem.text(`${count} s`); 27 | const timer = window.setInterval(() => { 28 | count--; 29 | if (count === 0) { 30 | window.clearInterval(timer); 31 | elem.attr('disabled', false); 32 | elem.text(text); 33 | } else { 34 | elem.text(`${count} s`); 35 | } 36 | }, 1000); 37 | $('#J_tips') 38 | .removeClass('alert-warning') 39 | .addClass('alert-success') 40 | .html('The token has been sent.') 41 | .show(); 42 | }).fail(err => { 43 | elem.attr('disabled', false); 44 | $('#J_tips') 45 | .removeClass('alert-success') 46 | .addClass('alert-warning') 47 | .html(err.responseJSON.message) 48 | .show(); 49 | }); 50 | }); 51 | const domain = window.location.protocol + '//' + window.location.host; 52 | const scan = { 53 | el: $('#J_scan_login'), 54 | canvas: document.getElementById('qrcode'), 55 | setupTimer(time) { 56 | const exp = time + (120 * 1000); 57 | this.cancelTimer(); 58 | this.timer = setInterval(() => { 59 | if (exp < Date.now()) { 60 | this.cancelTimer(); 61 | this.showError('QRCode Expired'); 62 | return; 63 | } 64 | this.fetch(); 65 | }, 2000); 66 | }, 67 | cancelTimer() { 68 | if (this.timer) { 69 | clearInterval(this.timer); 70 | } 71 | }, 72 | showError(msg) { 73 | $('.J_error_msg').text(msg); 74 | $('.qrcode-error').show(); 75 | }, 76 | fetch(renew) { 77 | $.post(this.action, { renew }).done((data) => { 78 | if (data.status === 1) { 79 | const url = `${this.url}?c=${data.code.id}`; 80 | QRCode.toCanvas(this.canvas, url, { 81 | width: 300, 82 | margin: 2 83 | }, (err) => { 84 | if (err) return this.showError('QRCode Error'); 85 | }); 86 | this.setupTimer(data.code.time); 87 | } 88 | if (data.status === 3) { 89 | this.showError('Login Timeout'); 90 | } 91 | if (data.status === 0) { 92 | this.cancelTimer(); 93 | window.location.reload(); 94 | } 95 | }).fail(() => { 96 | this.showError('QRCode Error'); 97 | }); 98 | }, 99 | init() { 100 | this.action = this.el.data('action'); 101 | this.url = domain + this.el.data('url'); 102 | $('.qrcode-error').hide(); 103 | this.fetch(1); 104 | }, 105 | show() { 106 | this.init(); 107 | this.el.show(); 108 | }, 109 | hide() { 110 | this.cancelTimer(); 111 | this.el.hide(); 112 | } 113 | }; 114 | const account = { 115 | el: $('#J_account_login'), 116 | show() { 117 | this.el.show(); 118 | }, 119 | hide() { 120 | this.el.hide(); 121 | } 122 | }; 123 | $('.J_refresh').on('click', () => { 124 | scan.init(); 125 | }); 126 | $('.J_login_nav').on('click', (e) => { 127 | const elem = $(e.currentTarget); 128 | $('.J_login_nav.active').removeClass('active'); 129 | elem.addClass('active'); 130 | const target = elem.data('target'); 131 | if (target === 'account') { 132 | scan.hide(); 133 | account.show(); 134 | } else { 135 | account.hide(); 136 | scan.show(); 137 | } 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-band/auth-center/99801449eb06ee2d6ca4bcc32f75289c1105eec4/assets/logo.png -------------------------------------------------------------------------------- /assets/main.scss: -------------------------------------------------------------------------------- 1 | body { 2 | background: #f5f5f5; 3 | } 4 | 5 | /* 6 | * Navbar 7 | */ 8 | 9 | .navbar-brand { 10 | padding-top: .5rem; 11 | padding-bottom: .5rem; 12 | font-size: 1rem; 13 | img { 14 | height: 2rem; 15 | } 16 | } 17 | 18 | .auth-form { 19 | width: 360px; 20 | margin: 0 auto; 21 | } 22 | 23 | .auth-form-header { 24 | margin-bottom: 15px; 25 | color: #333; 26 | text-align: center; 27 | 28 | h1 { 29 | font-size: 20px; 30 | font-weight: 300; 31 | } 32 | 33 | } 34 | 35 | .auth-form-body { 36 | border-top: 1px solid #d8dee2; 37 | padding: 20px; 38 | font-size: 14px; 39 | } 40 | 41 | .main { 42 | .app-item { 43 | display: block; 44 | position: relative; 45 | margin: 15px 0; 46 | padding: 8px; 47 | min-height: 64px; 48 | border-radius: 4px; 49 | text-decoration: none; 50 | 51 | &:hover { 52 | background-color: #fff; 53 | } 54 | .app-icon { 55 | position: absolute; 56 | width: 48px; 57 | height: 48px; 58 | border-radius: 6px; 59 | line-height: 48px; 60 | text-align: center; 61 | color: #fff; 62 | font-size: 28px; 63 | text-transform: uppercase; 64 | } 65 | .app-info { 66 | margin-left: 60px; 67 | line-height: 24px; 68 | 69 | .name { 70 | color: #333; 71 | font-size: 15px; 72 | font-weight: 500; 73 | } 74 | .name-cn { 75 | color: #888; 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /assets/reset.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | $('#J_reset').on('click', e => { 3 | const elem = $(e.currentTarget); 4 | elem.attr('disabled', true); 5 | $.post(elem.data('url'), { 6 | email: $('#J_email').val() 7 | }, null, 'json').done(() => { 8 | const text = elem.text(); 9 | let count = 60; 10 | elem.text(`${count} s`); 11 | const timer = window.setInterval(() => { 12 | count--; 13 | if (count === 0) { 14 | window.clearInterval(timer); 15 | elem.attr('disabled', false); 16 | elem.text(text); 17 | } else { 18 | elem.text(`${count} s`); 19 | } 20 | }, 1000); 21 | $('#J_tips') 22 | .removeClass('alert-warning') 23 | .addClass('alert-success') 24 | .html('The token has been sent.') 25 | .show(); 26 | }).fail(err => { 27 | elem.attr('disabled', false); 28 | $('#J_tips') 29 | .removeClass('alert-success') 30 | .addClass('alert-warning') 31 | .html(err.responseJSON.message) 32 | .show(); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /assets/roles.js: -------------------------------------------------------------------------------- 1 | const Select = require('./select'); 2 | 3 | $(function() { 4 | const elem = $('#J_userList'); 5 | new Select(elem, { 6 | data: function(q, cb) { 7 | $.post(elem.data('url'), { q }).done(cb); 8 | } 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /assets/select.js: -------------------------------------------------------------------------------- 1 | class Select { 2 | constructor(elem, options = {}) { 3 | this.$elem = typeof elem === 'string' ? $(elem) : elem; 4 | if (!this.$elem.length) return; 5 | this.options = options; 6 | this.$input = $('input', this.$elem); 7 | this.$list = $('.dropdown-menu', this.$elem); 8 | this.selected = false; 9 | this.bindEvent(); 10 | } 11 | bindEvent() { 12 | this.$elem.on('mousedown', '.dropdown-item', this.select.bind(this)); 13 | this.$input.on('focus', e => { 14 | this.filter(e.target.value); 15 | }).on('blur', e => { 16 | this.$list.removeClass('show'); 17 | }).keyup((e) => { 18 | const code = e.keyCode || e.which; 19 | if (code === 38) { 20 | const $cur = $('.active', this.$elem); 21 | const $prev = $cur.prev(); 22 | if ($prev.length) { 23 | $cur.removeClass('active'); 24 | $prev.addClass('active'); 25 | } 26 | return false; 27 | } 28 | if (code === 40) { 29 | const $cur = $('.active', this.$elem); 30 | const $next = $cur.next(); 31 | if ($next.length) { 32 | $cur.removeClass('active'); 33 | $next.addClass('active'); 34 | } 35 | return false; 36 | } 37 | if (code === 13) { 38 | $('.dropdown-item.active', this.$elem).trigger('mousedown'); 39 | return false; 40 | } 41 | 42 | this.selected = false; 43 | this.filter(e.target.value); 44 | }).keypress(e => { 45 | const code = e.keyCode || e.which; 46 | return code !== 13; 47 | }).on('change', (e) => { 48 | if (!this.selected) { 49 | $(e.target).val(''); 50 | } 51 | }); 52 | } 53 | select(e) { 54 | this.selected = true; 55 | const text = $(e.target).text(); 56 | this.$input.val(text); 57 | this.$list.removeClass('show'); 58 | } 59 | filter(text) { 60 | const {data} = this.options; 61 | if (typeof data === 'function') { 62 | data(text, list => { 63 | this.$list.addClass('show'); 64 | this.render(list, text); 65 | }); 66 | } else { 67 | if (data && data.length) { 68 | const list = data.filter(d => d.indexOf(text) >= 0); 69 | this.$list.addClass('show'); 70 | this.render(list, text); 71 | } 72 | } 73 | } 74 | render(list, text) { 75 | if (list.length) { 76 | let index = list.indexOf(text); 77 | index = index > -1 ? index : 0; 78 | const html = list.map((d, i) => { 79 | const c = i === index ? 'active' : ''; 80 | return `${d}`; 81 | }).join('\n'); 82 | this.$list.html(html); 83 | } else { 84 | this.$list.html('No Data'); 85 | } 86 | } 87 | } 88 | 89 | module.exports = Select; 90 | -------------------------------------------------------------------------------- /assets/variables.scss: -------------------------------------------------------------------------------- 1 | // Colors 2 | $gray-600: #868e96; 3 | 4 | $danger: #ff4d4f; 5 | $light: #f5f5f5; 6 | $text-muted: #9aa0ac; 7 | $font-weight-bold: 500; 8 | 9 | $dropdown-link-hover-bg: #eef2f5; 10 | 11 | // Borders 12 | // $border-color: #eee; 13 | $border-radius: 3px; 14 | $border-radius-lg: 3px; 15 | $border-radius-sm: 3px; 16 | $modal-content-border-width: 0; 17 | 18 | // Inputs 19 | $input-btn-focus-width: 2px; 20 | $input-focus-border-color: #1991eb; 21 | 22 | // Badges 23 | $badge-font-weight: 400; 24 | $badge-padding-x: .5em; 25 | -------------------------------------------------------------------------------- /bin/_config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /*Redis*/const redisStore = require('koa-redis');/*Redis*/ 3 | module.exports = { 4 | /*MySQL*/orm: { 5 | dialect: 'mysql', 6 | database: 'db_auth', 7 | username: 'root', 8 | password: null, 9 | host: '127.0.0.1', 10 | port: 3306, 11 | pool: { 12 | max: 5, 13 | idle: 3000 14 | } 15 | },/*MySQL*/ 16 | /*PostgreSQL*/orm: { 17 | dialect: 'postgres', 18 | database: 'db_auth', 19 | username: 'postgres', 20 | password: 'postgres', 21 | host: '127.0.0.1', 22 | port: 5432, 23 | pool: { 24 | max: 5, 25 | idle: 3000 26 | } 27 | },/*PostgreSQL*/ 28 | /*MariaDB*/orm: { 29 | dialect: 'mariadb', 30 | database: 'db_auth', 31 | username: 'root', 32 | password: null, 33 | host: '127.0.0.1', 34 | port: 3306, 35 | pool: { 36 | max: 5, 37 | idle: 3000 38 | } 39 | },/*MariaDB*/ 40 | /*MSSQL*/orm: { 41 | dialect: 'mssql', 42 | database: 'db_auth', 43 | username: 'sa', 44 | password: '123456', 45 | host: '127.0.0.1', 46 | port: 1433, 47 | pool: { 48 | max: 5, 49 | idle: 3000 50 | } 51 | },/*MSSQL*/ 52 | /*Redis*/session: { 53 | store: redisStore({ 54 | host: '127.0.0.1', 55 | port: 6379 56 | }) 57 | },/*Redis*/ 58 | domain: '__domain__', 59 | logo: '__logo__', 60 | isTOTP: true, 61 | mail: { 62 | from: '系统管理员 ', 63 | host: 'smtp.example.com', 64 | port: 465, 65 | secure: true, 66 | auth: { 67 | user: 'admin@example.com', 68 | pass: 'admin' 69 | } 70 | } 71 | }; 72 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | var program = require('commander'); 6 | 7 | program 8 | .version(require('../package').version, '-v, --version'); 9 | 10 | program 11 | .command('init') 12 | .description('init config') 13 | .action(require('./init')); 14 | 15 | program 16 | .command('start') 17 | .description('start server') 18 | .option("-p, --port ", "server port") 19 | .option('-c, --config ', 'custom config path') 20 | .option("--sync", "sync database to generate tables") 21 | .option("--data ", "init data with json file") 22 | .action(require('./start')); 23 | 24 | program.parse(process.argv); -------------------------------------------------------------------------------- /bin/init.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const inquirer = require('inquirer'); 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | const spawn = require('child_process').spawn; 7 | 8 | const databases = ['SQLite', 'MySQL', 'PostgreSQL', 'MariaDB', 'MSSQL']; 9 | 10 | function run(cmd, args, fn) { 11 | args = args || []; 12 | var runner = spawn(cmd, args, { 13 | stdio: "inherit" 14 | }); 15 | runner.on('close', function(code) { 16 | if (fn) { 17 | fn(code); 18 | } 19 | }); 20 | } 21 | 22 | module.exports = function() { 23 | let questions = [{ 24 | type: 'input', 25 | name: 'domain', 26 | message: 'Domain:', 27 | default: 'http://passport.example.com' 28 | }, { 29 | type: 'input', 30 | name: 'logo', 31 | message: 'Logo:', 32 | default: 'http://example.com/icon.png' 33 | }, { 34 | type: 'list', 35 | name: 'database', 36 | message: 'Database:', 37 | choices: databases 38 | }, { 39 | type: 'list', 40 | name: 'session', 41 | message: 'Session Store:', 42 | choices: ['Memory', 'Redis'] 43 | }]; 44 | 45 | inquirer 46 | .prompt(questions) 47 | .then(function(options) { 48 | let pkg = { 49 | name: path.basename(process.cwd()), 50 | version: '1.0.0', 51 | scripts: { 52 | start: 'auth-center start -c config.js' 53 | }, 54 | dependencies: {} 55 | }; 56 | 57 | if (options.session === 'Redis') { 58 | pkg.dependencies['koa-redis'] = 'latest'; 59 | } 60 | 61 | switch (options.database) { 62 | case 'SQLite': 63 | pkg.dependencies['sqlite3'] = 'latest'; 64 | break; 65 | case 'MySQL': 66 | pkg.dependencies['mysql2'] = 'latest'; 67 | break; 68 | case 'PostgreSQL': 69 | pkg.dependencies['pg'] = 'latest'; 70 | pkg.dependencies['pg-hstore'] = 'latest'; 71 | break; 72 | case 'MariaDB': 73 | pkg.dependencies['mysql2'] = 'latest'; 74 | break; 75 | case 'MSSQL': 76 | pkg.dependencies['tedious'] = 'latest'; 77 | break; 78 | } 79 | 80 | fs.writeFileSync('package.json', JSON.stringify(pkg, null, ' '), 'utf8'); 81 | 82 | console.log('Generate package.json done.'); 83 | 84 | let tpl = fs.readFileSync(path.join(__dirname, '_config.js'), 'utf8'); 85 | 86 | tpl = tpl 87 | .replace(/__domain__/, options.domain) 88 | .replace(/__logo__/, options.logo); 89 | 90 | databases.push('Redis'); 91 | 92 | for (let db of databases) { 93 | let r = new RegExp('/\\*' + db + '\\*/([\\s\\S]*?)/\\*' + db + '\\*/', 'gm'); 94 | if (options.database === db || options.session === db) { 95 | tpl = tpl.replace(r, '$1\n'); 96 | } else { 97 | tpl = tpl.replace(r, ''); 98 | } 99 | } 100 | 101 | tpl = tpl.replace(/^\s*[\r\n]/gm, ''); 102 | 103 | fs.writeFileSync('config.js', tpl, 'utf8'); 104 | 105 | console.log('Generate config.js done.'); 106 | 107 | run('npm', ['install'], function() { 108 | console.log('npm install done.'); 109 | }); 110 | }); 111 | }; 112 | -------------------------------------------------------------------------------- /bin/start.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const AuthServer = require('../app'); 4 | const resolve = require('path').resolve; 5 | const { existsSync } = require('fs'); 6 | 7 | module.exports = function(options) { 8 | const configPath = options.config || 'config.js'; 9 | const port = options.port || 3000; 10 | let server; 11 | 12 | if (existsSync(configPath)) { 13 | server = AuthServer(configPath); 14 | } else { 15 | server = AuthServer(); 16 | } 17 | 18 | server.listen(port); 19 | 20 | console.log('Running site at: http://127.0.0.1:' + port); 21 | 22 | async function init() { 23 | const { sync, User } = server.orm.database(); 24 | 25 | if (options.sync) { 26 | await sync({ force: true }); 27 | console.log('sync done.'); 28 | } 29 | 30 | if (options.data && existsSync(options.data)) { 31 | const data = require(resolve(options.data)); 32 | const users = data.users || []; 33 | 34 | for (let user of users) { 35 | await User.add(user); 36 | } 37 | 38 | console.log('load data done.'); 39 | } 40 | } 41 | 42 | init().catch((err) => { 43 | console.log(err.stack || err); 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /examples/config1.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | domain: 'http://passport.example.com', 3 | orm: { 4 | db: 'db_auth', 5 | username: 'root', 6 | password: '112358', 7 | // Supported: 'mysql', 'sqlite', 'postgres', 'mariadb' 8 | dialect: 'mysql', 9 | host: '127.0.0.1', 10 | port: 3306, 11 | pool: { 12 | maxConnections: 10, 13 | minConnections: 0, 14 | maxIdleTime: 30000 15 | } 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /examples/example1.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Server = require('../src'); 4 | 5 | const server = Server({ 6 | debug: true, 7 | domain: 'http://passport.example.com', 8 | orm: { 9 | database: 'auth', 10 | username: 'root', 11 | password: '112358', 12 | // Supported: 'mysql', 'sqlite', 'postgres', 'mariadb' 13 | dialect: 'mysql', 14 | host: '127.0.0.1', 15 | port: 3306, 16 | pool: { 17 | maxConnections: 10, 18 | minConnections: 0, 19 | maxIdleTime: 30000 20 | } 21 | }, 22 | mail: { 23 | from: '系统管理员 ', 24 | host: 'smtp.example.com', 25 | port: 465, 26 | secure: true, 27 | auth: { 28 | user: 'admin@example.com', 29 | pass: 'admin' 30 | } 31 | } 32 | }); 33 | 34 | /** Start **/ 35 | if (!module.parent) { 36 | const port = 8888; 37 | 38 | server.listen(port); 39 | console.log(`Running site at: http://127.0.0.1:${port}`); 40 | // Sync Database to generate tables 41 | server.orm.database().sync({ 42 | force: false 43 | }).then(() => { 44 | console.log('Sync done.'); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /examples/example2.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const App = require('../app'); 4 | const co = require('co'); 5 | 6 | const app = App({ 7 | debug: true, 8 | isTOTP: true, 9 | mail: { 10 | from: 'admin@example.com', 11 | name: 'minimal', 12 | version: '0.1.0', 13 | send: function(mail, callback) { 14 | const input = mail.message.createReadStream(); 15 | const chunks = []; 16 | input.on('data', function(chunk) { 17 | chunks.push(chunk); 18 | }); 19 | input.on('end', function() { 20 | const data = Buffer.concat(chunks).toString(); 21 | console.log(data); 22 | callback(null, true); 23 | }); 24 | } 25 | } 26 | }); 27 | 28 | /** Start **/ 29 | if (!module.parent) { 30 | const port = 8888; 31 | app.listen(port); 32 | co(function*() { 33 | const { sync, User, Client, DicRole } = app.orm.database(); 34 | yield sync({ 35 | force: true 36 | }); 37 | yield User.add({ 38 | id: 10001, 39 | password: 'nick', 40 | email: 'nick@example.com', 41 | totp_key: '1234', 42 | is_admin: true 43 | }); 44 | yield User.add({ 45 | id: 10002, 46 | password: 'ken', 47 | email: 'ken@example.com', 48 | totp_key: '1234', 49 | is_admin: true 50 | }); 51 | yield Client.create({ 52 | id: '740a1d6d-9df8-4552-a97a-5704681b8039', 53 | name: 'local', 54 | secret: '12345678', 55 | redirect_uri: 'http://localhost:8080' 56 | }); 57 | yield Client.create({ 58 | id: 'bd0e56c1-8f02-49f3-b502-129da70b6f09', 59 | name: 'test', 60 | secret: '12345678', 61 | redirect_uri: 'http://localhost:9090' 62 | }); 63 | yield DicRole.create({ 64 | name: 'user', 65 | description: 'Normal user' 66 | }); 67 | yield DicRole.create({ 68 | name: 'document', 69 | description: 'Document department' 70 | }); 71 | }).then(() => { 72 | console.log(`Running site at: http://127.0.0.1:${port}`); 73 | }); 74 | } 75 | -------------------------------------------------------------------------------- /i18n/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "Security": "安全", 3 | "Logout": "退出", 4 | "New password": "新密码", 5 | "Confirm password": "确认密码", 6 | "Sign in to system": "登录系统", 7 | "Email": "邮箱", 8 | "Password": "密码", 9 | "Dynamic token": "动态口令", 10 | "Send": "发送", 11 | "I have read and agree to": "我已阅读并同意", 12 | "the terms of use.": "《使用条款》", 13 | "Sign In": "登录", 14 | "Lost your password?": "找回密码?", 15 | "Retrieve your password": "找回密码", 16 | "Enter the account you want to retrieve the password": "请输入您要找回密码的账号", 17 | "Enter your email address": "请输入邮箱地址", 18 | "Captcha": "验证码", 19 | "Next": "下一步", 20 | "Home": "首页", 21 | "Change password": "修改密码", 22 | "Current password": "当前密码", 23 | "Submit": "提交", 24 | "Password is required": "请输入密码", 25 | "Old and new password can not be the same": "新密码不能与旧密码相同", 26 | "Passwords do not match": "两次输入的密码不匹配", 27 | "Password length atleast 8": "密码长度最少为8位", 28 | "Old password is invalid": "旧密码错误", 29 | "Password have been changed": "密码修改成功,请重新登录", 30 | "Email is required": "请输入邮箱", 31 | "Token is required": "请输入动态口令", 32 | "You should agree the terms": "您需要同意使用条款", 33 | "Please try 5 minutes later": "请5分钟后重试", 34 | "Email or password is invalid": "邮箱和密码不匹配", 35 | "Token is invalid": "动态口令错误", 36 | "Captcha is required": "请输入验证码", 37 | "Try again in a minute": "一分钟后重试", 38 | "Email is invalid": "邮箱格式不正确", 39 | "Captcha is invalid": "验证码错误", 40 | "The token has been sent.": "动态口令已发送", 41 | "Refresh QRCode": "刷新二维码", 42 | "Account Login": "账号登录", 43 | "Scan Login": "扫码登录" 44 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auth-center", 3 | "version": "2.2.0", 4 | "description": "auth center with TOTP", 5 | "main": "app/index.js", 6 | "bin": { 7 | "auth-center": "bin/cli.js" 8 | }, 9 | "scripts": { 10 | "start": "babel-node scripts/server.js", 11 | "dev": "cd assets && dool server -p 7777", 12 | "build": "npm run build-app && npm run build-assets", 13 | "build-app": "rimraf app && babel src --out-dir app", 14 | "build-assets": "rimraf public && cd assets && dool build -o $PWD/../public", 15 | "prepare": "npm run build", 16 | "test": "NODE_ENV=test nyc mocha --exit", 17 | "report": "nyc report --reporter=html", 18 | "coveralls": "nyc report --reporter=text-lcov | coveralls", 19 | "lint": "eslint --ext .js src test" 20 | }, 21 | "nyc": { 22 | "include": [ 23 | "src/**/*.js" 24 | ], 25 | "require": [ 26 | "@babel/register" 27 | ], 28 | "sourceMap": false, 29 | "instrument": false 30 | }, 31 | "pre-commit": [ 32 | "lint" 33 | ], 34 | "repository": { 35 | "type": "git", 36 | "url": "git+https://github.com/d-band/auth-center.git" 37 | }, 38 | "keywords": [ 39 | "auth", 40 | "center", 41 | "TOTP", 42 | "HOTP" 43 | ], 44 | "author": "d-band", 45 | "license": "MIT", 46 | "bugs": { 47 | "url": "https://github.com/d-band/auth-center/issues" 48 | }, 49 | "homepage": "https://github.com/d-band/auth-center#readme", 50 | "engines": { 51 | "node": ">= 8" 52 | }, 53 | "devDependencies": { 54 | "@babel/cli": "^7.12.1", 55 | "@babel/node": "^7.12.6", 56 | "@babel/register": "^7.12.1", 57 | "babel-eslint": "^10.0.3", 58 | "babel-plugin-add-module-exports": "^1.0.4", 59 | "babel-plugin-istanbul": "^6.0.0", 60 | "bootstrap": "^4.5.3", 61 | "chai": "^4.2.0", 62 | "chai-http": "^4.3.0", 63 | "coveralls": "^3.0.14", 64 | "dool": "^4.3.7", 65 | "eslint": "^7.13.0", 66 | "eslint-config-standard": "^16.0.1", 67 | "eslint-plugin-import": "^2.22.1", 68 | "eslint-plugin-node": "^11.1.0", 69 | "eslint-plugin-promise": "^4.2.1", 70 | "jquery": "^3.5.1", 71 | "koa-passport": "^4.1.3", 72 | "mocha": "^8.2.1", 73 | "mysql2": "^2.2.5", 74 | "nyc": "^15.1.0", 75 | "passport-oauth2": "^1.5.0", 76 | "popper.js": "^1.16.1", 77 | "pre-commit": "^1.1.3", 78 | "qrcode": "^1.4.4", 79 | "rimraf": "^3.0.0", 80 | "sass": "^1.29.0", 81 | "sass-loader": "^10.0.5", 82 | "sqlite3": "^5.0.0" 83 | }, 84 | "dependencies": { 85 | "commander": "^6.2.0", 86 | "inquirer": "^7.3.3", 87 | "koa": "^2.13.0", 88 | "koa-bodyparser": "^4.3.0", 89 | "koa-csrf": "^3.0.8", 90 | "koa-logger": "^3.2.1", 91 | "koa-orm": "^3.2.1", 92 | "koa-router": "^10.0.0", 93 | "koa-session": "^6.1.0", 94 | "koa-static": "^5.0.0", 95 | "koa-view": "^2.1.4", 96 | "lodash.merge": "^4.6.2", 97 | "lodash.template": "^4.5.0", 98 | "nanoid": "^3.1.16", 99 | "nodemailer": "^6.4.15", 100 | "otplib": "^12.0.1", 101 | "qr-image": "^3.1.0", 102 | "randomcolor": "^0.6.2", 103 | "validator": "^13.1.17" 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /scripts/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Server from '../src'; 4 | 5 | const staticPath = 'http://localhost:7777'; 6 | const server = Server({ 7 | debug: true, 8 | // isTOTP: false, 9 | staticPath, 10 | logo: `${staticPath}/logo.png`, 11 | favicon: `${staticPath}/logo.png`, 12 | mail: { 13 | from: 'admin@example.com', 14 | name: 'minimal', 15 | version: '0.1.0', 16 | send: (mail, callback) => { 17 | const input = mail.message.createReadStream(); 18 | const chunks = []; 19 | input.on('data', (chunk) => { 20 | chunks.push(chunk); 21 | }); 22 | input.on('end', () => { 23 | const data = Buffer.concat(chunks).toString(); 24 | console.log(data); 25 | callback(null, true); 26 | }); 27 | } 28 | } 29 | }); 30 | 31 | /** Start **/ 32 | if (!module.parent) { 33 | const port = 8888; 34 | // init 35 | async function init() { 36 | const { 37 | sync, User, Client, DicRole, Role 38 | } = server.orm.database(); 39 | await sync({ 40 | force: true 41 | }); 42 | await User.add({ 43 | id: 10001, 44 | password: 'nick', 45 | email: 'nick@example.com', 46 | totp_key: '1234', 47 | is_admin: true 48 | }); 49 | await User.add({ 50 | id: 10002, 51 | password: 'ken', 52 | email: 'ken@example.com', 53 | totp_key: '1234', 54 | is_admin: true 55 | }); 56 | await Client.create({ 57 | id: '740a1d6d-9df8-4552-a97a-5704681b8039', 58 | name: 'local', 59 | name_cn: '本地系统', 60 | secret: '12345678', 61 | redirect_uri: 'http://localhost:8080' 62 | }); 63 | await Client.create({ 64 | id: 'bd0e56c1-8f02-49f3-b502-129da70b6f09', 65 | name: 'test', 66 | name_cn: '测试系统', 67 | secret: '12345678', 68 | redirect_uri: 'http://localhost:9090' 69 | }); 70 | await DicRole.create({ 71 | name: 'user', 72 | description: 'Normal user' 73 | }); 74 | await DicRole.create({ 75 | name: 'document', 76 | description: 'Document department' 77 | }); 78 | await Role.create({ 79 | user_id: 10001, 80 | client_id: '740a1d6d-9df8-4552-a97a-5704681b8039', 81 | role: 'user' 82 | }); 83 | await Role.create({ 84 | user_id: 10001, 85 | client_id: 'bd0e56c1-8f02-49f3-b502-129da70b6f09', 86 | role: 'document' 87 | }); 88 | await Role.create({ 89 | user_id: 10002, 90 | client_id: '740a1d6d-9df8-4552-a97a-5704681b8039', 91 | role: 'user' 92 | }); 93 | } 94 | 95 | init().then(() => { 96 | server.listen(port); 97 | console.log('\nInit database done.'); 98 | console.log(`\nRunning site at:\x1B[36m http://127.0.0.1:${port}\x1B[39m`); 99 | }); 100 | } 101 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { join, resolve } from 'path'; 4 | import merge from 'lodash.merge'; 5 | 6 | const config = { 7 | proxy: true, 8 | debug: process.env.NODE_ENV !== 'production', 9 | staticPath: join(__dirname, '../public'), 10 | viewPath: join(__dirname, '../views'), 11 | keys: ['auth', 'center'], 12 | session: { 13 | key: 'sid' 14 | }, 15 | domain: '__domain__', 16 | logo: '/logo.png', 17 | favicon: '/logo.png', 18 | terms: 'https://en.wikipedia.org/wiki/Terms_of_service', 19 | tokenLimit: 3 * 3600, 20 | recoveryTokenTTL: 5 * 60, 21 | // I18N config 22 | messages: {}, 23 | // OAuth config 24 | isTOTP: true, 25 | codeTTL: 10 * 60, 26 | accessTokenTTL: 12 * 3600, 27 | refreshTokenTTL: 30 * 24 * 3600, 28 | // ORM config 29 | orm: { 30 | database: ':memory:', 31 | dialect: 'sqlite', 32 | modelPath: join(__dirname, 'models') 33 | }, 34 | // Mail config 35 | mail: { 36 | templates: { 37 | send_totp: { 38 | subject: '[Important] The key of the dynamic token', 39 | html: ` 40 |

Hello, {{username}}, following image is the key for dynamic token.

41 |

42 |

Or you can use following email and secret key to register :

43 |

Email: {{email}}
Secret Key: {{key}}

44 |

You should download Takey to scan it.

45 |

iOS App: https://itunes.apple.com/cn/app/takey/id1447343446
46 | Android App: https://dl.jsmartx.com/app/takey.apk

47 |

Thanks!

48 | ` 49 | }, 50 | login_token: { 51 | subject: '{{token}} is your sign in token', 52 | html: ` 53 | Dear {{username}} 54 |

Your dynamic token is {{token}}. It will be expired in 5 minutes. 55 |



To make sure our emails arrive, please add {{sender}} to your contacts. 56 | ` 57 | }, 58 | resetpwd_token: { 59 | subject: '{{token}} is your retrieve password captcha', 60 | html: ` 61 |

Hello, {{username}}, we heard that you lost your password. Sorry about that!
62 | But don’t worry! You can use the captcha to reset your password: {{token}}. It will be expired in 5 minutes. 63 |

64 |

To make sure our emails arrive, please add {{sender}} to your contacts.

65 | ` 66 | } 67 | } 68 | }, 69 | routes: { 70 | home: '/', 71 | login: '/login', 72 | logout: '/logout', 73 | scan: '/scan', 74 | qrcode: '/qrcode', 75 | scan_login: '/scan_login', 76 | password_reset: '/password_reset', 77 | password_change: '/password_change', 78 | login_token: '/login_token', 79 | resetpwd_token: '/resetpwd_token', 80 | resetpwd_auth: '/resetpwd_auth', 81 | session: '/session', 82 | user: '/user', 83 | authorize: '/authorize', 84 | access_token: '/access_token', 85 | security: '/security', 86 | security_change: '/security_change', 87 | admin: { 88 | users: '/admin', 89 | search_user: '/admin/search_user', 90 | clients: '/admin/clients', 91 | roles: '/admin/roles', 92 | send_totp: '/admin/send_totp', 93 | add_client: '/admin/add_client', 94 | generate_secret: '/admin/generate_secret', 95 | add_role: '/admin/add_role', 96 | delete_role: '/admin/delete_role' 97 | } 98 | } 99 | }; 100 | 101 | export default function initConfig (param) { 102 | if (typeof param === 'string') { 103 | const customConfig = require(resolve(param)); 104 | merge(config, customConfig); 105 | } 106 | 107 | if (typeof param === 'object') { 108 | merge(config, param); 109 | } 110 | 111 | if (!config.orm.dialectModulePath) { 112 | const modulePath = join(process.cwd(), 'node_modules'); 113 | const moduleName = ({ 114 | sqlite: 'sqlite3', 115 | mysql: 'mysql2', 116 | mariadb: 'mysql2', 117 | postgres: 'pg', 118 | mssql: 'tedious' 119 | })[config.orm.dialect]; 120 | 121 | config.orm.dialectModulePath = require.resolve(join(modulePath, moduleName)); 122 | } 123 | 124 | if (!config.orm.logging) { 125 | config.orm.logging = config.debug && console.log; 126 | } 127 | return config; 128 | } 129 | -------------------------------------------------------------------------------- /src/controllers/admin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import qs from 'querystring'; 4 | import { generateId, encodeKey, totpImage } from '../util'; 5 | 6 | export async function checkLogin (ctx, next) { 7 | if (ctx.session.user) { 8 | if (ctx.session.user.is_admin) { 9 | ctx.state.user = ctx.session.user; 10 | await next(); 11 | } else { 12 | ctx.redirect(ctx._routes.home); 13 | } 14 | } else { 15 | ctx.session.returnTo = ctx.url; 16 | ctx.redirect(ctx._routes.login); 17 | } 18 | } 19 | 20 | export async function searchUser (ctx) { 21 | const { User, Op } = ctx.orm(); 22 | const q = ctx.request.body.q || ''; 23 | const users = await User.findAll({ 24 | attributes: ['email'], 25 | where: { 26 | enable: 1, 27 | email: { 28 | [Op.like]: q + '%' 29 | } 30 | }, 31 | offset: 0, 32 | limit: 15 33 | }); 34 | ctx.body = users.map(u => u.email); 35 | } 36 | 37 | export async function userList (ctx) { 38 | const { User, Op } = ctx.orm(); 39 | const page = parseInt(ctx.query.p, 10) || 1; 40 | const query = ctx.query.q || ''; 41 | const limit = 20; 42 | const offset = (page - 1) * limit; 43 | const where = { enable: 1 }; 44 | if (query) { 45 | where.email = { [Op.like]: `%${query}%` }; 46 | } 47 | const users = await User.findAndCountAll({ 48 | where, 49 | limit, 50 | offset, 51 | order: [['email', 'ASC']], 52 | attributes: { 53 | exclude: ['pass_hash', 'pass_salt'] 54 | } 55 | }); 56 | 57 | await ctx.render('admin/users', { 58 | page, 59 | users, 60 | query, 61 | navUsers: 'active', 62 | total: Math.ceil(users.count / limit), 63 | link: p => `?${qs.stringify({ q: query, p })}` 64 | }); 65 | } 66 | 67 | export async function clientList (ctx) { 68 | const { Client, Op } = ctx.orm(); 69 | const page = parseInt(ctx.query.p, 10) || 1; 70 | const query = ctx.query.q || ''; 71 | const limit = 20; 72 | const offset = (page - 1) * limit; 73 | const where = {}; 74 | if (query) { 75 | where.name = { [Op.like]: `%${query}%` }; 76 | } 77 | const clients = await Client.findAndCountAll({ 78 | where, 79 | limit, 80 | offset, 81 | order: [['name', 'ASC']], 82 | attributes: ['id', 'secret', 'redirect_uri', 'name', 'name_cn'] 83 | }); 84 | 85 | await ctx.render('admin/clients', { 86 | page, 87 | query, 88 | clients, 89 | navClients: 'active', 90 | total: Math.ceil(clients.count / limit), 91 | link: p => `?${qs.stringify({ q: query, p })}` 92 | }); 93 | } 94 | 95 | export async function sendTotp (ctx) { 96 | const { User } = ctx.orm(); 97 | const { id } = ctx.request.body; 98 | 99 | if (!id) { 100 | ctx.flash('error', 'ID is required'); 101 | ctx.redirect(ctx._routes.admin.users); 102 | return; 103 | } 104 | 105 | try { 106 | // generate new totp key 107 | const res = await User.update({ 108 | totp_key: generateId() 109 | }, { 110 | where: { id } 111 | }); 112 | // send email 113 | if (res[0]) { 114 | const user = await User.findByPk(id); 115 | await ctx.sendMail(user.email, 'send_totp', { 116 | username: user.email, 117 | cid: 'key', 118 | email: user.email, 119 | key: encodeKey(user.totp_key) 120 | }, [{ 121 | filename: 'key.png', 122 | content: totpImage(user.email, user.totp_key), 123 | cid: 'key' 124 | }]); 125 | } else { 126 | ctx.flash('error', 'Update failed'); 127 | ctx.redirect(ctx._routes.admin.users); 128 | return; 129 | } 130 | ctx.flash('success', 'Reset and send TOTP key successfully'); 131 | ctx.redirect(ctx._routes.admin.users); 132 | } catch (e) { 133 | console.error(e.stack); 134 | ctx.flash('error', 'Reset and send key failed'); 135 | ctx.redirect(ctx._routes.admin.users); 136 | } 137 | } 138 | 139 | export async function addClient (ctx) { 140 | const { Client } = ctx.orm(); 141 | const { name, name_cn, redirect_uri } = ctx.request.body; 142 | 143 | if (!name) { 144 | ctx.flash('error', 'Name is required'); 145 | ctx.redirect(ctx._routes.admin.clients); 146 | return; 147 | } 148 | 149 | if (!name_cn) { 150 | ctx.flash('error', 'Name CN is required'); 151 | ctx.redirect(ctx._routes.admin.clients); 152 | return; 153 | } 154 | 155 | if (!redirect_uri) { 156 | ctx.flash('error', 'Redirect URI is required'); 157 | ctx.redirect(ctx._routes.admin.clients); 158 | return; 159 | } 160 | 161 | try { 162 | // add one new 163 | await Client.create({ 164 | secret: generateId(), 165 | name: name, 166 | name_cn: name_cn, 167 | redirect_uri: redirect_uri 168 | }); 169 | ctx.flash('success', 'Add new client successfully'); 170 | ctx.redirect(ctx._routes.admin.clients); 171 | } catch (e) { 172 | console.error(e.stack); 173 | ctx.flash('error', 'Add new client failed'); 174 | ctx.redirect(ctx._routes.admin.clients); 175 | } 176 | } 177 | 178 | export async function generateSecret (ctx) { 179 | const { Client } = ctx.orm(); 180 | const { id } = ctx.request.body; 181 | 182 | if (!id) { 183 | ctx.flash('error', 'ID is required'); 184 | ctx.redirect(ctx._routes.admin.clients); 185 | return; 186 | } 187 | 188 | try { 189 | // generate new secret 190 | const res = await Client.update({ 191 | secret: generateId() 192 | }, { 193 | where: { id } 194 | }); 195 | if (res[0]) { 196 | ctx.flash('success', 'Generate new secret successfully'); 197 | ctx.redirect(ctx._routes.admin.clients); 198 | } else { 199 | ctx.flash('error', 'Update failed'); 200 | ctx.redirect(ctx._routes.admin.clients); 201 | return; 202 | } 203 | } catch (e) { 204 | console.error(e.stack); 205 | ctx.flash('error', 'Generate new secret failed'); 206 | ctx.redirect(ctx._routes.admin.clients); 207 | } 208 | } 209 | 210 | export async function roleList (ctx) { 211 | const { User, Role, Client, DicRole, Op } = ctx.orm(); 212 | const page = parseInt(ctx.query.p, 10) || 1; 213 | const query = ctx.query.q || ''; 214 | const client = ctx.query.client; 215 | const where = {}; 216 | if (query) { 217 | const temp = await User.findAll({ 218 | offset: 0, 219 | limit: 100, 220 | attributes: ['id'], 221 | where: { 222 | email: { [Op.like]: `%${query}%` } 223 | } 224 | }); 225 | where.user_id = { 226 | [Op.in]: temp.map(v => v.id) 227 | }; 228 | } 229 | if (client) { 230 | where.client_id = client; 231 | } 232 | const limit = 20; 233 | const offset = (page - 1) * limit; 234 | const roles = await Role.findAndCountAll({ 235 | where, 236 | limit, 237 | offset, 238 | order: [['user_id', 'ASC']], 239 | attributes: ['id', 'user_id', 'client_id', 'role'] 240 | }); 241 | const users = await User.findAll({ 242 | attributes: ['id', 'email'], 243 | where: { 244 | id: { [Op.in]: roles.rows.map(v => v.user_id) } 245 | } 246 | }); 247 | const userMap = users.reduce((o, c) => { 248 | o[c.id] = c.email; 249 | return o; 250 | }, {}); 251 | 252 | const clients = await Client.findAll(); 253 | const dics = await DicRole.findAll(); 254 | const clientMap = clients.reduce((o, c) => { 255 | o[c.id] = c.name; 256 | return o; 257 | }, {}); 258 | await ctx.render('admin/roles', { 259 | dics, 260 | page, 261 | query, 262 | client, 263 | roles, 264 | clients, 265 | userMap, 266 | clientMap, 267 | navRoles: 'active', 268 | total: Math.ceil(roles.count / limit), 269 | link: p => `?${qs.stringify({ q: query, p })}` 270 | }); 271 | } 272 | 273 | export async function addRole (ctx) { 274 | const { Role, User } = ctx.orm(); 275 | const { email, client, role } = ctx.request.body; 276 | 277 | if (!email) { 278 | ctx.flash('error', 'Email is required'); 279 | ctx.redirect(ctx._routes.admin.roles); 280 | return; 281 | } 282 | 283 | if (!client) { 284 | ctx.flash('error', 'Client is required'); 285 | ctx.redirect(ctx._routes.admin.roles); 286 | return; 287 | } 288 | 289 | if (!role) { 290 | ctx.flash('error', 'Role is required'); 291 | ctx.redirect(ctx._routes.admin.roles); 292 | return; 293 | } 294 | 295 | const user = await User.findOne({ 296 | attributes: ['id'], 297 | where: { email, enable: 1 } 298 | }); 299 | if (!user) { 300 | ctx.flash('error', 'User is not existed'); 301 | ctx.redirect(ctx._routes.admin.roles); 302 | return; 303 | } 304 | 305 | try { 306 | // add one new 307 | await Role.create({ 308 | user_id: user.id, 309 | client_id: client, 310 | role: role 311 | }); 312 | 313 | ctx.flash('success', 'Add new role successfully'); 314 | ctx.redirect(ctx._routes.admin.roles); 315 | } catch (e) { 316 | console.error(e.stack); 317 | ctx.flash('error', 'Add new role failed, maybe it is existed'); 318 | ctx.redirect(ctx._routes.admin.roles); 319 | } 320 | } 321 | 322 | export async function deleteRole (ctx) { 323 | const { Role } = ctx.orm(); 324 | const { id } = ctx.request.body; 325 | 326 | if (!id) { 327 | ctx.flash('error', 'Id is required'); 328 | ctx.redirect(ctx._routes.admin.roles); 329 | return; 330 | } 331 | // Delete role 332 | const num = await Role.destroy({ 333 | where: { id } 334 | }); 335 | 336 | if (num <= 0) { 337 | ctx.flash('error', 'Delete role failed, maybe it is not existed'); 338 | ctx.redirect(ctx._routes.admin.roles); 339 | return; 340 | } 341 | 342 | ctx.flash('success', 'Delete role successfully'); 343 | ctx.redirect(ctx._routes.admin.roles); 344 | } 345 | -------------------------------------------------------------------------------- /src/controllers/scan.js: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid'; 2 | import { totp } from '../util'; 3 | 4 | export async function qrcode (ctx) { 5 | const { QRCode, User } = ctx.orm(); 6 | const { renew } = ctx.request.body; 7 | const key = 'LOGIN_QRCODE'; 8 | const code = ctx.session[key]; 9 | const exp = Date.now() - (120 * 1000); 10 | if (renew || !code || code.time < exp) { 11 | const newCode = { 12 | id: nanoid(), 13 | time: Date.now() 14 | }; 15 | ctx.session[key] = newCode; 16 | ctx.body = { code: newCode, status: 1 }; 17 | return; 18 | } 19 | const qr = await QRCode.findByPk(code.id); 20 | if (!qr) { 21 | ctx.body = { status: 2 }; 22 | return; 23 | } 24 | const time = Date.now() - (30 * 1000); 25 | if (qr.createdAt.getTime() < time) { 26 | ctx.body = { status: 3 }; 27 | return; 28 | } 29 | const uid = qr.user_id; 30 | await qr.destroy(); 31 | ctx.session.user = await User.findByPk(uid, { 32 | attributes: ['id', 'email', 'enable', 'is_admin'] 33 | }); 34 | ctx.body = { status: 0 }; 35 | } 36 | 37 | export async function login (ctx) { 38 | const { User, QRCode } = ctx.orm(); 39 | const { code, token } = ctx.request.body; 40 | const user = await User.findByPk(ctx._userId, { 41 | attributes: ['totp_key'] 42 | }); 43 | 44 | ctx.assert(user, 400, 'User not found'); 45 | if (ctx.config.isTOTP) { 46 | const isOk = totp.check(token, user.totp_key); 47 | ctx.assert(isOk, 400, 'TOTP code invalid'); 48 | } 49 | 50 | await QRCode.create({ 51 | id: code, 52 | user_id: ctx._userId 53 | }); 54 | ctx.body = { status: 0 }; 55 | } 56 | -------------------------------------------------------------------------------- /src/controllers/user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import isEmail from 'validator/lib/isEmail'; 4 | import randomColor from 'randomcolor'; 5 | import { totp, getCaptcha } from '../util'; 6 | 7 | export async function home (ctx) { 8 | const { Role, Client, Op } = ctx.orm(); 9 | const { user } = ctx.session; 10 | const roles = await Role.findAll({ 11 | attributes: ['client_id'], 12 | where: { user_id: user.id } 13 | }); 14 | const clients = await Client.findAll({ 15 | attributes: ['name', 'name_cn', 'redirect_uri'], 16 | where: { 17 | id: { [Op.in]: roles.map(r => r.client_id) }, 18 | redirect_uri: { [Op.ne]: '' } 19 | } 20 | }); 21 | const colors = randomColor({ 22 | luminosity: 'dark', 23 | count: clients.length 24 | }); 25 | await ctx.render('main/home', { user, clients, colors }); 26 | } 27 | 28 | export async function security (ctx) { 29 | const { user } = ctx.session; 30 | await ctx.render('main/security', { user }); 31 | } 32 | 33 | export async function securityChange (ctx) { 34 | const { User } = ctx.orm(); 35 | const { oldpwd, newpwd, renewpwd } = ctx.request.body; 36 | const { user } = ctx.session; 37 | 38 | if (!oldpwd || !newpwd || !renewpwd) { 39 | ctx.flash('error', 'Password is required'); 40 | ctx.redirect(ctx._routes.security); 41 | return; 42 | } 43 | 44 | if (newpwd === oldpwd) { 45 | ctx.flash('error', 'Old and new password can not be the same'); 46 | ctx.redirect(ctx._routes.security); 47 | return; 48 | } 49 | 50 | // 验证一把新密码是否相同 51 | if (newpwd !== renewpwd) { 52 | ctx.flash('error', 'Passwords do not match'); 53 | ctx.redirect(ctx._routes.security); 54 | return; 55 | } 56 | 57 | if (newpwd.length < 8) { 58 | ctx.flash('error', 'Password length atleast 8'); 59 | ctx.redirect(ctx._routes.security); 60 | return; 61 | } 62 | 63 | const auth = await User.auth(user.email, oldpwd); 64 | 65 | if (!auth) { 66 | ctx.flash('error', 'Old password is invalid'); 67 | ctx.redirect(ctx._routes.security); 68 | return; 69 | } 70 | 71 | await User.changePassword(user.id, newpwd); 72 | await ctx.log(user.email, 'CHANGE_PWD'); 73 | 74 | ctx.session.user = null; 75 | ctx.flash('success', 'Password have been changed'); 76 | ctx.redirect(ctx._routes.login); 77 | } 78 | 79 | export async function checkLogin (ctx, next) { 80 | if (ctx.session.user) { 81 | await next(); 82 | } else { 83 | ctx.session.returnTo = ctx.url; 84 | ctx.redirect(ctx._routes.login); 85 | } 86 | } 87 | 88 | export async function login (ctx) { 89 | if (ctx.session.user) { 90 | const returnTo = ctx.session.returnTo; 91 | ctx.session.returnTo = null; 92 | ctx.redirect(returnTo || ctx._routes.home); 93 | return; 94 | } 95 | await ctx.render('auth/login', { 96 | isTOTP: ctx.config.isTOTP 97 | }); 98 | } 99 | 100 | export async function session (ctx) { 101 | const { User } = ctx.orm(); 102 | const { email, password, token, terms } = ctx.request.body; 103 | 104 | if (!email) { 105 | ctx.flash('error', 'Email is required'); 106 | ctx.redirect(ctx._routes.login); 107 | return; 108 | } 109 | if (!password) { 110 | ctx.flash('error', 'Password is required'); 111 | ctx.redirect(ctx._routes.login); 112 | return; 113 | } 114 | if (ctx.config.isTOTP && !token) { 115 | ctx.flash('error', 'Token is required'); 116 | ctx.redirect(ctx._routes.login); 117 | return; 118 | } 119 | if (ctx.config.terms && String(terms) !== '1') { 120 | ctx.flash('error', 'You should agree the terms'); 121 | ctx.redirect(ctx._routes.login); 122 | return; 123 | } 124 | 125 | // 5分钟内限制使用5次 126 | const count = await ctx.logCount(email, 'LOGIN_ERR', 300); 127 | if (count >= 5) { 128 | ctx.flash('error', 'Please try 5 minutes later'); 129 | ctx.redirect(ctx._routes.login); 130 | return; 131 | } 132 | 133 | const user = await User.auth(email, password); 134 | if (!user) { 135 | await ctx.log(email, 'LOGIN_ERR'); 136 | ctx.flash('error', 'Email or password is invalid'); 137 | ctx.redirect(ctx._routes.login); 138 | return; 139 | } 140 | if (ctx.config.isTOTP && !totp.check(token, user.totp_key)) { 141 | await ctx.log(email, 'LOGIN_ERR'); 142 | ctx.flash('error', 'Token is invalid'); 143 | ctx.redirect(ctx._routes.login); 144 | return; 145 | } 146 | 147 | const returnTo = ctx.session.returnTo; 148 | 149 | ctx.session.returnTo = null; 150 | delete user.totp_key; 151 | ctx.session.user = user; 152 | await ctx.log(email, 'LOGIN'); 153 | ctx.redirect(returnTo || ctx._routes.home); 154 | } 155 | 156 | export async function logout (ctx, next) { 157 | const returnTo = ctx.query.return_to; 158 | ctx.session.user = null; 159 | ctx.redirect(returnTo || ctx._routes.login); 160 | await next(); 161 | } 162 | 163 | export async function passwordResetPage (ctx) { 164 | await ctx.render('auth/reset'); 165 | } 166 | 167 | export async function passwordChangePage (ctx) { 168 | const { email, token } = ctx.query; 169 | await ctx.render('auth/change', { 170 | token, 171 | email, 172 | title: 'Change password' 173 | }); 174 | } 175 | 176 | export async function passwordChange (ctx) { 177 | const { User, Recovery } = ctx.orm(); 178 | const { user } = ctx; 179 | const { password, password2, email } = ctx.request.body; 180 | 181 | if (!password || !password2) { 182 | ctx.flash('error', 'Password is required'); 183 | ctx.redirect('back'); 184 | return; 185 | } 186 | 187 | if (password !== password2) { 188 | ctx.flash('error', 'Passwords do not match'); 189 | ctx.redirect('back'); 190 | return; 191 | } 192 | 193 | if (password.length < 8) { 194 | ctx.flash('error', 'Password length atleast 8'); 195 | ctx.redirect('back'); 196 | return; 197 | } 198 | 199 | // TODO: add transaction 200 | await User.changePassword(user.id, password); 201 | await ctx.log(email, 'CHANGE_PWD'); 202 | await Recovery.destroy({ 203 | where: { user_id: user.id } 204 | }); 205 | 206 | ctx.flash('success', 'Password have been changed'); 207 | ctx.redirect(ctx._routes.login); 208 | } 209 | 210 | export async function getInfo (ctx) { 211 | const { User } = ctx.orm(); 212 | 213 | ctx.body = await User.findByPk(ctx._userId, { 214 | attributes: ['id', 'email'], 215 | raw: true 216 | }); 217 | } 218 | 219 | export function checkToken (key) { 220 | return async (ctx, next) => { 221 | const { email } = ctx.request.body; 222 | const timeKey = `${key}_TIME`; 223 | const lastTime = ctx.session[timeKey]; 224 | 225 | ctx.assert(email, 400, 'Email is required'); 226 | ctx.assert(isEmail(email), 400, 'Email is invalid'); 227 | // wait 1 minute 228 | const min = 60 * 1000; 229 | const now = Date.now(); 230 | ctx.assert(!lastTime || (now - lastTime) > min, 400, 'Try again in a minute'); 231 | 232 | const count = await ctx.logCount(email, key, ctx.config.tokenLimit); 233 | ctx.assert(count < 5, 400, `Only 5 times allowed within ${ctx.config.tokenLimit / 3600} hours`); 234 | 235 | await next(); 236 | 237 | await ctx.log(email, key); 238 | ctx.session[timeKey] = now; 239 | ctx.body = { code: 0 }; 240 | }; 241 | } 242 | 243 | export async function loginToken (ctx) { 244 | const { User } = ctx.orm(); 245 | const { email } = ctx.request.body; 246 | const user = await User.findOne({ 247 | attributes: ['id', 'totp_key'], 248 | where: { email, enable: 1 } 249 | }); 250 | 251 | if (!user) return; 252 | 253 | await ctx.sendMail(email, 'login_token', { 254 | username: email, 255 | sender: ctx.config.mail.from, 256 | token: totp.generate(user.totp_key) 257 | }); 258 | } 259 | 260 | export async function resetpwdToken (ctx) { 261 | const { User, Recovery } = ctx.orm(); 262 | const { email } = ctx.request.body; 263 | const user = await User.findOne({ 264 | attributes: ['id'], 265 | where: { email, enable: 1 } 266 | }); 267 | 268 | if (!user) return; 269 | 270 | // 删除之前的Token 271 | await Recovery.destroy({ 272 | where: { user_id: user.id } 273 | }); 274 | const recovery = await Recovery.create({ 275 | user_id: user.id, 276 | token: getCaptcha(8) 277 | }); 278 | await ctx.sendMail(email, 'resetpwd_token', { 279 | username: email, 280 | sender: ctx.config.mail.from, 281 | token: recovery.token 282 | }); 283 | } 284 | 285 | export async function checkResetToken (ctx, next) { 286 | const { User, Recovery } = ctx.orm(); 287 | const email = ctx.query.email || ctx.request.body.email; 288 | const token = ctx.query.token || ctx.request.body.token; 289 | 290 | if (!email) { 291 | ctx.flash('error', 'Email is required'); 292 | ctx.redirect(ctx._routes.password_reset); 293 | return; 294 | } 295 | 296 | if (!token) { 297 | ctx.flash('error', 'Captcha is required'); 298 | ctx.redirect(ctx._routes.password_reset); 299 | return; 300 | } 301 | 302 | // 1分钟内限制使用3次,超过次数删除token 303 | const count = await ctx.logCount(email, 'RESETPWD_ERR', 60); 304 | if (count >= 3) { 305 | ctx.flash('error', 'Try again in a minute'); 306 | ctx.redirect(ctx._routes.password_reset); 307 | return; 308 | } 309 | const user = await User.findOne({ 310 | attributes: ['id'], 311 | where: { email, enable: 1 } 312 | }); 313 | if (!user) { 314 | await ctx.log(email, 'RESETPWD_ERR'); 315 | ctx.flash('error', 'Email is invalid'); 316 | ctx.redirect(ctx._routes.password_reset); 317 | return; 318 | } 319 | // token是否存在 320 | const recovery = await Recovery.findOne({ 321 | attributes: ['createdAt'], 322 | where: { 323 | token, 324 | user_id: user.id 325 | } 326 | }); 327 | const time = Date.now() - (1000 * ctx.config.recoveryTokenTTL); 328 | if (!recovery || recovery.createdAt.getTime() < time) { 329 | await ctx.log(email, 'RESETPWD_ERR'); 330 | ctx.flash('error', 'Captcha is invalid'); 331 | ctx.redirect(ctx._routes.password_reset); 332 | return; 333 | } 334 | ctx.user = user; 335 | await next(); 336 | } 337 | -------------------------------------------------------------------------------- /src/i18n.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { existsSync } from 'fs'; 4 | import { join } from 'path'; 5 | 6 | export default function i18n (messages) { 7 | const dir = join(__dirname, '../i18n'); 8 | const _userMessages = messages || {}; 9 | const _messages = {}; 10 | return (ctx, next) => { 11 | if (ctx.query.locale) { 12 | ctx.session.locale = ctx.query.locale; 13 | } 14 | const lang = ctx.session.locale || 'en'; 15 | if (!_messages[lang]) { 16 | const temp = _userMessages[lang] || _userMessages['*'] || {}; 17 | const file = join(dir, `${lang}.json`); 18 | if (existsSync(file)) { 19 | _messages[lang] = Object.assign(require(file), temp); 20 | } else { 21 | _messages[lang] = Object.assign({}, temp); 22 | } 23 | } 24 | ctx.state.__ = (key) => (_messages[lang][key] || key); 25 | return next(); 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Koa from 'koa'; 4 | import view from 'koa-view'; 5 | import bodyParser from 'koa-bodyparser'; 6 | import session from 'koa-session'; 7 | import logger from 'koa-logger'; 8 | import serve from 'koa-static'; 9 | import orm from 'koa-orm'; 10 | 11 | import i18n from './i18n'; 12 | import config from './config'; 13 | import routes from './routes'; 14 | import error from './middlewares/error'; 15 | import flash from './middlewares/flash'; 16 | import mail from './middlewares/mail'; 17 | import log from './middlewares/log'; 18 | import { pagination, isURL } from './util'; 19 | 20 | export default function (options) { 21 | const cfg = config(options); 22 | const app = new Koa(); 23 | // Has reversed by proxy 24 | app.proxy = cfg.proxy === true; 25 | 26 | const { staticPath } = cfg; 27 | app.use(async function injectConfig (ctx, next) { 28 | ctx.__defineGetter__('config', config); 29 | ctx._routes = cfg.routes; 30 | ctx.state._routes = cfg.routes; 31 | ctx.state.logo = cfg.logo; 32 | ctx.state.terms = cfg.terms; 33 | ctx.state.favicon = cfg.favicon; 34 | ctx.state.staticRoot = isURL(staticPath) ? staticPath : ''; 35 | await next(); 36 | }); 37 | 38 | /** Set public path, for css/js/images **/ 39 | if (!isURL(staticPath)) { 40 | app.use(serve(cfg.staticPath, { 41 | maxage: cfg.debug ? 0 : 60 * 60 * 24 * 7 42 | })); 43 | } 44 | 45 | app.use(bodyParser()); 46 | app.use(logger()); 47 | 48 | /** Sessions **/ 49 | app.keys = cfg.keys; 50 | app.use(session(cfg.session, app)); 51 | 52 | /** I18n **/ 53 | app.use(i18n(config.messages)); 54 | 55 | /** View & i18n **/ 56 | app.use(view(cfg.viewPath, { 57 | noCache: cfg.debug, 58 | globals: { 59 | pagination 60 | } 61 | })); 62 | 63 | /** ORM **/ 64 | app.orm = orm(cfg.orm); 65 | app.use(app.orm.middleware); 66 | 67 | /** Middlewares **/ 68 | error(app); 69 | flash(app); 70 | log(app); 71 | mail(app, cfg.mail); 72 | 73 | /** Router **/ 74 | routes(app, cfg); 75 | 76 | return app; 77 | } 78 | -------------------------------------------------------------------------------- /src/middlewares/error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export default function (app) { 4 | app.use(async function errorHandler (ctx, next) { 5 | try { 6 | await next(); 7 | if (ctx.response.status === 404 && !ctx.response.body) ctx.throw(404); 8 | } catch (err) { 9 | console.error(err.stack || err); 10 | 11 | const status = err.status || 500; 12 | const code = err.code || 'server_error'; 13 | const message = ctx.state.__(err.message || 'Server error'); 14 | 15 | ctx.app.emit('error', err, ctx); 16 | 17 | ctx.status = status; 18 | switch (ctx.accepts('html', 'text', 'json')) { 19 | case 'text': 20 | ctx.type = 'text/plain'; 21 | ctx.body = message; 22 | break; 23 | case 'html': 24 | ctx.type = 'text/html'; 25 | await ctx.render('error', { 26 | status: status, 27 | message: message 28 | }); 29 | break; 30 | default: 31 | ctx.type = 'application/json'; 32 | ctx.body = { code, message }; 33 | } 34 | } 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/middlewares/flash.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export default function (app) { 4 | const key = 'messages'; 5 | 6 | app.use(async function flashHandler (ctx, next) { 7 | ctx.state[key] = ctx.session[key] || {}; 8 | 9 | delete ctx.session[key]; 10 | 11 | ctx.flash = function (type, msg) { 12 | ctx.session[key] = ctx.session[key] || {}; 13 | ctx.session[key][type] = msg; 14 | }; 15 | 16 | await next(); 17 | 18 | if (ctx.status === 302 && ctx.session && !(ctx.session[key])) { 19 | ctx.session[key] = ctx.state[key]; 20 | } 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/middlewares/log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ACTIONS = { 4 | LOGIN: 1, 5 | CHANGE_PWD: 2, 6 | LOGIN_TOKEN: 3, 7 | RESETPWD_TOKEN: 4, 8 | LOGIN_ERR: 5, 9 | RESETPWD_ERR: 6 10 | }; 11 | 12 | export default function (app) { 13 | app.use(async function logFn (ctx, next) { 14 | const { Log, Op } = ctx.orm(); 15 | ctx.log = async (user, action) => { 16 | try { 17 | await Log.create({ 18 | user, 19 | ip: ctx.ip, 20 | action: ACTIONS[action] 21 | }); 22 | } catch (e) { 23 | console.error(e); 24 | } 25 | }; 26 | ctx.logCount = async (user, action, seconds) => { 27 | const date = new Date(Date.now() - 1000 * seconds); 28 | return await Log.count({ 29 | where: { 30 | user, 31 | action: ACTIONS[action], 32 | createdAt: { [Op.gt]: date } 33 | } 34 | }); 35 | }; 36 | await next(); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /src/middlewares/mail.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import nm from 'nodemailer'; 4 | import template from 'lodash.template'; 5 | 6 | export default function (app, options = {}) { 7 | const templates = options.templates || {}; 8 | const transport = nm.createTransport(options); 9 | Object.keys(templates).forEach(k => { 10 | templates[k].title = template(templates[k].subject, { 11 | interpolate: /{{([\s\S]+?)}}/g 12 | }); 13 | templates[k].render = template(templates[k].html, { 14 | interpolate: /{{([\s\S]+?)}}/g 15 | }); 16 | }); 17 | app.use(async function mailHandler (ctx, next) { 18 | ctx.sendMail = (to, key, data, files) => new Promise((resolve, reject) => { 19 | const tpl = templates[key]; 20 | transport.sendMail({ 21 | from: options.from, 22 | to: to, 23 | attachments: files, 24 | subject: tpl.title(data), 25 | html: tpl.render(data) 26 | }, (err) => { 27 | if (err) { 28 | console.error(err); 29 | reject(new Error('Send failed')); 30 | } else { 31 | resolve(); 32 | } 33 | }); 34 | }); 35 | 36 | await next(); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /src/models/client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export default function (sequelize, DataTypes) { 4 | return sequelize.define('Client', { 5 | id: { 6 | type: DataTypes.UUID, 7 | defaultValue: DataTypes.UUIDV4, 8 | primaryKey: true, 9 | comment: 'Client Id' 10 | }, 11 | secret: { 12 | type: DataTypes.STRING(100), 13 | allowNull: false, 14 | comment: 'Client Secret' 15 | }, 16 | redirect_uri: { 17 | type: DataTypes.STRING(255), 18 | allowNull: false, 19 | comment: 'Redirect URI' 20 | }, 21 | name: { 22 | type: DataTypes.STRING(100), 23 | allowNull: false, 24 | comment: 'Client Name' 25 | }, 26 | name_cn: { 27 | type: DataTypes.STRING(100), 28 | allowNull: false, 29 | defaultValue: '', 30 | comment: 'Client Name CN' 31 | } 32 | }, { 33 | tableName: 'client', 34 | comment: 'Client Table' 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/models/code.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export default function (sequelize, DataTypes) { 4 | return sequelize.define('Code', { 5 | id: { 6 | type: DataTypes.UUID, 7 | defaultValue: DataTypes.UUIDV4, 8 | primaryKey: true, 9 | comment: 'Authorization Code' 10 | }, 11 | client_id: { 12 | type: DataTypes.STRING(100), 13 | allowNull: false, 14 | comment: 'Client Id' 15 | }, 16 | user_id: { 17 | type: DataTypes.BIGINT.UNSIGNED, 18 | allowNull: false, 19 | comment: 'User Id' 20 | }, 21 | redirect_uri: { 22 | type: DataTypes.STRING(255), 23 | allowNull: false, 24 | comment: 'Redirect URI' 25 | } 26 | }, { 27 | tableName: 'code', 28 | comment: 'Authorization Code Table', 29 | indexes: [{ 30 | fields: ['client_id'] 31 | }] 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /src/models/dic_role.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export default function (sequelize, DataTypes) { 4 | return sequelize.define('DicRole', { 5 | name: { 6 | type: DataTypes.STRING(100), 7 | allowNull: false, 8 | primaryKey: true, 9 | comment: 'Role Name' 10 | }, 11 | description: { 12 | type: DataTypes.STRING(100), 13 | defaultValue: '', 14 | comment: 'Role Description' 15 | } 16 | }, { 17 | tableName: 'dic_role', 18 | comment: 'Role Dic Table' 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/models/log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export default function (sequelize, DataTypes) { 4 | return sequelize.define('Log', { 5 | id: { 6 | type: DataTypes.BIGINT.UNSIGNED, 7 | allowNull: false, 8 | primaryKey: true, 9 | autoIncrement: true 10 | }, 11 | ip: { 12 | type: DataTypes.STRING(64), 13 | allowNull: false, 14 | comment: 'Request IP' 15 | }, 16 | user: { 17 | type: DataTypes.STRING(100), 18 | allowNull: false, 19 | comment: 'User login account' 20 | }, 21 | action: { 22 | type: DataTypes.INTEGER, 23 | allowNull: false, 24 | comment: 'User Action' 25 | } 26 | }, { 27 | tableName: 'log', 28 | comment: 'Log Table' 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /src/models/qrcode.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export default function (sequelize, DataTypes) { 4 | return sequelize.define('QRCode', { 5 | id: { 6 | type: DataTypes.STRING(24), 7 | primaryKey: true, 8 | allowNull: false, 9 | comment: 'QRCode Id' 10 | }, 11 | user_id: { 12 | type: DataTypes.BIGINT.UNSIGNED, 13 | allowNull: false, 14 | comment: 'User Id' 15 | } 16 | }, { 17 | tableName: 'qrcode', 18 | comment: 'QRCode Table' 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/models/recovery.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export default function (sequelize, DataTypes) { 4 | return sequelize.define('Recovery', { 5 | user_id: { 6 | type: DataTypes.BIGINT.UNSIGNED, 7 | allowNull: false, 8 | comment: 'User Id' 9 | }, 10 | token: { 11 | type: DataTypes.STRING, 12 | allowNull: false, 13 | comment: 'Recovery Token' 14 | } 15 | }, { 16 | tableName: 'recovery', 17 | comment: 'Account Recovery Table', 18 | indexes: [{ 19 | fields: ['user_id'] 20 | }] 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/models/role.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export default function (sequelize, DataTypes) { 4 | return sequelize.define('Role', { 5 | user_id: { 6 | type: DataTypes.BIGINT.UNSIGNED, 7 | allowNull: false, 8 | comment: 'User Id' 9 | }, 10 | client_id: { 11 | type: DataTypes.STRING(100), 12 | allowNull: false, 13 | comment: 'Client Id' 14 | }, 15 | role: { 16 | type: DataTypes.STRING(100), 17 | allowNull: false, 18 | comment: 'Role' 19 | } 20 | }, { 21 | tableName: 'role', 22 | comment: 'Role Table, user roles of the clients', 23 | indexes: [{ 24 | unique: true, 25 | fields: ['user_id', 'client_id', 'role'] 26 | }] 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/models/token.js: -------------------------------------------------------------------------------- 1 | import { generateId } from '../util'; 2 | 3 | export default function (sequelize, DataTypes) { 4 | return sequelize.define('Token', { 5 | id: { 6 | type: DataTypes.STRING(40), 7 | defaultValue: generateId, 8 | primaryKey: true, 9 | comment: 'Access Token' 10 | }, 11 | client_id: { 12 | type: DataTypes.STRING(100), 13 | allowNull: false, 14 | comment: 'Client Id' 15 | }, 16 | user_id: { 17 | type: DataTypes.BIGINT.UNSIGNED, 18 | allowNull: false, 19 | comment: 'User Id' 20 | }, 21 | ttl: { 22 | type: DataTypes.INTEGER.UNSIGNED, 23 | allowNull: false, 24 | comment: 'Time To Live (Second)' 25 | }, 26 | refresh_token: { 27 | type: DataTypes.STRING(40), 28 | defaultValue: generateId, 29 | unique: true, 30 | allowNull: false, 31 | comment: 'Refresh Token' 32 | } 33 | }, { 34 | tableName: 'token', 35 | comment: 'Access Token Table' 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /src/models/user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { makeSalt, encrypt } from '../util'; 4 | 5 | export default function (sequelize, DataTypes) { 6 | const User = sequelize.define('User', { 7 | id: { 8 | type: DataTypes.BIGINT.UNSIGNED, 9 | allowNull: false, 10 | primaryKey: true 11 | }, 12 | email: { 13 | type: DataTypes.STRING(100), 14 | unique: true, 15 | allowNull: false, 16 | comment: 'User Email' 17 | }, 18 | pass_salt: { 19 | type: DataTypes.STRING(100), 20 | allowNull: false, 21 | comment: 'User Password Salt' 22 | }, 23 | pass_hash: { 24 | type: DataTypes.STRING(100), 25 | allowNull: false, 26 | comment: 'User Password Hash' 27 | }, 28 | totp_key: { 29 | type: DataTypes.STRING(100), 30 | defaultValue: '', 31 | comment: 'User TOTP Key' 32 | }, 33 | enable: { 34 | type: DataTypes.BOOLEAN(), 35 | defaultValue: true 36 | }, 37 | is_admin: { 38 | type: DataTypes.BOOLEAN(), 39 | defaultValue: false 40 | } 41 | }, { 42 | tableName: 'user', 43 | comment: 'User Table' 44 | }); 45 | 46 | User.auth = async function (email, password) { 47 | const user = await this.findOne({ 48 | where: { email, enable: 1 } 49 | }); 50 | if (!user) return null; 51 | if (user.pass_hash !== encrypt(password, user.pass_salt)) { 52 | return null; 53 | } 54 | 55 | user.pass_hash = null; 56 | user.pass_salt = null; 57 | return user; 58 | }; 59 | 60 | User.add = function ({ id, password, email, totp_key, is_admin }, options) { 61 | const salt = makeSalt(); 62 | const hash = encrypt(password, salt); 63 | return this.create({ 64 | id, 65 | email, 66 | totp_key, 67 | is_admin, 68 | pass_salt: salt, 69 | pass_hash: hash 70 | }, options); 71 | }; 72 | 73 | User.changePassword = function (id, newPwd) { 74 | const salt = makeSalt(); 75 | const hash = encrypt(newPwd, salt); 76 | return this.update({ 77 | pass_salt: salt, 78 | pass_hash: hash 79 | }, { 80 | where: { id } 81 | }); 82 | }; 83 | 84 | return User; 85 | } 86 | -------------------------------------------------------------------------------- /src/oauth/errors.js: -------------------------------------------------------------------------------- 1 | const tokenErrors = { 2 | invalid_request: 400, 3 | invalid_client: 401, 4 | invalid_grant: 400, 5 | unauthorized_client: 401, 6 | unsupported_grant_type: 400, 7 | invalid_scope: 400, 8 | invalid_token: 401 9 | }; 10 | const codeErrors = { 11 | invalid_redirect_uri: 400, 12 | unrecognized_client_id: 400, 13 | invalid_request: 400, 14 | unauthorized_client: 401, 15 | access_denied: 403, 16 | unsupported_response_type: 400, 17 | invalid_scope: 400 18 | }; 19 | const reducer = obj => Object.keys(obj).reduce((prev, code) => ({ 20 | ...prev, 21 | [code]: (message, props) => ({ 22 | message: message || code, 23 | status: obj[code], 24 | props: { ...props, code } 25 | }) 26 | }), {}); 27 | 28 | export const TokenErrors = reducer(tokenErrors); 29 | export const CodeErrors = reducer(codeErrors); 30 | 31 | export function assert (ctx, value, { status, message, props }) { 32 | ctx.assert(value, status, message, props); 33 | } 34 | -------------------------------------------------------------------------------- /src/oauth/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { checkURI, buildURI } from '../util'; 4 | import { TokenErrors, CodeErrors, assert } from './errors'; 5 | 6 | export default function (config) { 7 | async function authorize (ctx) { 8 | const { Client, Code } = ctx.orm(); 9 | 10 | const user = ctx.session.user; 11 | const { client_id, response_type, redirect_uri, state } = ctx.query; 12 | assert(ctx, client_id, CodeErrors.unrecognized_client_id('client_id is missing')); 13 | 14 | const client = await Client.findByPk(client_id); 15 | assert(ctx, client, CodeErrors.unrecognized_client_id('client not found')); 16 | 17 | if (redirect_uri) { 18 | const isChecked = checkURI(client.redirect_uri, redirect_uri); 19 | assert(ctx, isChecked, CodeErrors.invalid_redirect_uri('redirect_uri is invalid')); 20 | } 21 | const uri = redirect_uri || client.redirect_uri; 22 | if (!response_type) { 23 | return ctx.redirect(buildURI(uri, { 24 | state, 25 | error: 'invalid_request' 26 | })); 27 | } 28 | if (response_type !== 'code') { 29 | return ctx.redirect(buildURI(uri, { 30 | state, 31 | error: 'unsupported_response_type' 32 | })); 33 | } 34 | const code = await Code.create({ 35 | user_id: user.id, 36 | client_id: client.id, 37 | redirect_uri: uri 38 | }); 39 | ctx.redirect(buildURI(uri, { 40 | code: code.id, 41 | state: state 42 | })); 43 | } 44 | 45 | async function accessToken (ctx) { 46 | const { Client, Token, Code } = ctx.orm(); 47 | 48 | const isForm = ctx.request.is('application/x-www-form-urlencoded'); 49 | assert(ctx, isForm, TokenErrors.invalid_request('content-type is invalid')); 50 | 51 | const { grant_type, client_id, client_secret, state } = ctx.request.body; 52 | assert(ctx, client_id, TokenErrors.invalid_request('client_id is missing')); 53 | assert(ctx, client_secret, TokenErrors.invalid_request('client_secret is missing')); 54 | 55 | const client = await Client.findByPk(client_id); 56 | assert(ctx, client, TokenErrors.invalid_client('client_id is invalid')); 57 | assert(ctx, client.secret === client_secret, TokenErrors.invalid_client('client_secret is invalid')); 58 | 59 | const obj = { 60 | client_id: client.id, 61 | ttl: config.accessTokenTTL 62 | }; 63 | if (grant_type === 'authorization_code') { 64 | const { code: code_id, redirect_uri } = ctx.request.body; 65 | assert(ctx, redirect_uri, TokenErrors.invalid_request('redirect_uri is missing')); 66 | assert(ctx, code_id, TokenErrors.invalid_request('code is missing')); 67 | 68 | const code = await Code.findByPk(code_id, { 69 | where: { client_id: client.id } 70 | }); 71 | assert(ctx, code, TokenErrors.invalid_grant('code is invalid')); 72 | 73 | const expiresAt = code.createdAt.getTime() + (config.codeTTL * 1000); 74 | assert(ctx, expiresAt > Date.now(), TokenErrors.invalid_grant('code has expired')); 75 | 76 | const isChecked = checkURI(code.redirect_uri, redirect_uri); 77 | assert(ctx, isChecked, TokenErrors.invalid_grant('redirect_uri is invalid')); 78 | obj.user_id = code.user_id; 79 | await code.destroy(); 80 | } else if (grant_type === 'refresh_token') { 81 | const { refresh_token } = ctx.request.body; 82 | assert(ctx, refresh_token, TokenErrors.invalid_request('refresh_token is missing')); 83 | 84 | const token = await Token.findOne({ 85 | where: { 86 | refresh_token, 87 | client_id: client.id 88 | } 89 | }); 90 | assert(ctx, token, TokenErrors.invalid_grant('refresh_token is invalid')); 91 | 92 | const expiresAt = token.createdAt.getTime() + (config.refreshTokenTTL * 1000); 93 | assert(ctx, expiresAt > Date.now(), TokenErrors.invalid_grant('refresh_token has expired')); 94 | // Reuse refresh token 95 | obj.user_id = token.user_id; 96 | obj.refresh_token = refresh_token; 97 | await token.destroy(); 98 | } else { 99 | assert(ctx, false, TokenErrors.unsupported_grant_type('unsupported grant type')); 100 | } 101 | const result = await Token.create(obj); 102 | ctx.set('Cache-Control', 'no-store'); 103 | ctx.set('Pragma', 'no-cache'); 104 | ctx.body = { 105 | access_token: result.id, 106 | refresh_token: result.refresh_token, 107 | token_type: 'bearer', 108 | expires_in: result.ttl, 109 | state: state 110 | }; 111 | } 112 | 113 | async function authenticate (ctx, next) { 114 | const { Token } = ctx.orm(); 115 | 116 | let tokenId = ctx.get('authorization'); 117 | const matches = tokenId.match(/bearer\s(\S+)/i); 118 | if (!matches) { 119 | tokenId = ctx.query.access_token; 120 | } else { 121 | tokenId = matches[1]; 122 | } 123 | assert(ctx, tokenId, TokenErrors.invalid_token('access_token is missing')); 124 | 125 | const token = await Token.findByPk(tokenId); 126 | assert(ctx, token, TokenErrors.invalid_token('access_token is invalid')); 127 | 128 | const expiresAt = token.createdAt.getTime() + (token.ttl * 1000); 129 | assert(ctx, expiresAt > Date.now(), TokenErrors.invalid_token('access_token has expired')); 130 | 131 | ctx._userId = token.user_id; 132 | await next(); 133 | } 134 | 135 | return { 136 | authorize, 137 | accessToken, 138 | authenticate 139 | }; 140 | } 141 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Router from 'koa-router'; 4 | import CSRF from 'koa-csrf'; 5 | import OAuth from './oauth'; 6 | import * as user from './controllers/user'; 7 | import * as admin from './controllers/admin'; 8 | import * as scan from './controllers/scan'; 9 | 10 | export default function routes (app, config) { 11 | const R = config.routes; 12 | const oauth = OAuth(config); 13 | const authRouter = new Router(); 14 | // API: get user info 15 | authRouter.get(R.user, oauth.authenticate, user.getInfo); 16 | // OAuth 17 | authRouter.get(R.authorize, user.checkLogin, oauth.authorize); 18 | authRouter.post(R.access_token, oauth.accessToken); 19 | authRouter.post(R.scan_login, oauth.authenticate, scan.login); 20 | 21 | const router = new Router(); 22 | router.get(R.home, user.checkLogin, user.home); 23 | router.get(R.security, user.checkLogin, user.security); 24 | router.post(R.security_change, user.checkLogin, user.securityChange); 25 | // Login & Logout 26 | router.get(R.login, user.login); 27 | router.get(R.logout, user.logout); 28 | router.post(R.login_token, user.checkToken('LOGIN_TOKEN'), user.loginToken); 29 | router.post(R.session, user.session); 30 | // Scan Login 31 | router.post(R.qrcode, scan.qrcode); 32 | router.redirect(R.scan, R.login); 33 | // Reset password 34 | router.get(R.password_reset, user.passwordResetPage); 35 | router.post(R.resetpwd_token, user.checkToken('RESETPWD_TOKEN'), user.resetpwdToken); 36 | // Change password 37 | router.get(R.password_change, user.checkResetToken, user.passwordChangePage); 38 | router.post(R.password_change, user.checkResetToken, user.passwordChange); 39 | // Admin 40 | router.get(R.admin.users, admin.checkLogin, admin.userList); 41 | router.post(R.admin.search_user, admin.checkLogin, admin.searchUser); 42 | router.get(R.admin.clients, admin.checkLogin, admin.clientList); 43 | router.post(R.admin.send_totp, admin.checkLogin, admin.sendTotp); 44 | router.post(R.admin.add_client, admin.checkLogin, admin.addClient); 45 | router.post(R.admin.generate_secret, admin.checkLogin, admin.generateSecret); 46 | router.get(R.admin.roles, admin.checkLogin, admin.roleList); 47 | router.post(R.admin.add_role, admin.checkLogin, admin.addRole); 48 | router.post(R.admin.delete_role, admin.checkLogin, admin.deleteRole); 49 | 50 | app.use(authRouter.routes()); 51 | app.use(authRouter.allowedMethods()); 52 | app.use(new CSRF()); 53 | app.use(async (ctx, next) => { 54 | ctx.state._csrf = ctx.csrf; 55 | ctx.cookies.set('XSRF-TOKEN', ctx.csrf, { 56 | httpOnly: false 57 | }); 58 | await next(); 59 | }); 60 | app.use(router.routes()); 61 | app.use(router.allowedMethods()); 62 | } 63 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import crypto from 'crypto'; 4 | import { totp, authenticator } from 'otplib'; 5 | import qr from 'qr-image'; 6 | import { nanoid, customAlphabet } from 'nanoid'; 7 | import { parse, format } from 'url'; 8 | 9 | totp.options = { window: 10 }; 10 | export { totp }; 11 | 12 | export function isURL (str) { 13 | return /^(https?:|)(\/\/)/i.test(str); 14 | } 15 | 16 | export function pagination (cur, total, link, half = 2) { 17 | if (total <= 1) return ''; 18 | 19 | let left = Math.max(1, cur - half); 20 | const right = Math.min(left + half * 2, total); 21 | 22 | if (total - cur <= half) { 23 | left = Math.max(1, total - half * 2); 24 | } 25 | const temp = ['
    ']; 26 | if (cur > 1) { 27 | temp.push(`
  • «
  • `); 28 | } 29 | 30 | for (let i = left; i <= right; i++) { 31 | temp.push(`
  • ${i}
  • `); 32 | } 33 | if (cur !== total) { 34 | temp.push(`
  • »
  • `); 35 | } 36 | temp.push('
'); 37 | return temp.join(''); 38 | } 39 | 40 | export function makeSalt () { 41 | return Math.round(Date.now() * Math.random()).toString(); 42 | } 43 | 44 | export function encrypt (pass, salt) { 45 | if (!pass) return null; 46 | return crypto.createHmac('sha1', salt).update(pass).digest('hex'); 47 | } 48 | 49 | export function checkURI (base, checked) { 50 | const url1 = parse(base, false, true); 51 | const url2 = parse(checked, false, true); 52 | 53 | url1.port = url1.port || 80; 54 | url2.port = url2.port || 80; 55 | url1.pathname = url1.pathname || '/'; 56 | url2.pathname = url2.pathname || '/'; 57 | 58 | return url1.hostname === url2.hostname && 59 | url1.port === url2.port && 60 | url2.pathname.indexOf(url1.pathname) === 0; 61 | } 62 | 63 | export function generateId () { 64 | return nanoid(40); 65 | } 66 | 67 | export function getCaptcha (len) { 68 | return customAlphabet('1234567890', len)(); 69 | } 70 | 71 | export function buildURI (uri, query) { 72 | const obj = parse(uri, true); 73 | Object.assign(obj.query, query); 74 | delete obj.search; 75 | return format(obj); 76 | } 77 | 78 | export function encodeKey (key) { 79 | authenticator.options = { encoding: 'ascii' }; 80 | return authenticator.encode(key); 81 | } 82 | 83 | export function totpURI (user, key) { 84 | return `otpauth://totp/${user}?secret=${encodeKey(key)}`; 85 | } 86 | 87 | export function totpImage (user, key) { 88 | return qr.imageSync(totpURI(user, key), 'H'); 89 | } 90 | -------------------------------------------------------------------------------- /test/client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Router from 'koa-router'; 4 | import passport from 'koa-passport'; 5 | import { Strategy } from 'passport-oauth2'; 6 | 7 | const store = {}; 8 | let isHeader = true; 9 | 10 | module.exports = function (app) { 11 | Strategy.prototype.userProfile = function (accessToken, done) { 12 | this._oauth2.useAuthorizationHeaderforGET(isHeader); 13 | isHeader = !isHeader; 14 | this._oauth2.get('http://localhost:3000/user', accessToken, function (err, body) { 15 | if (err) { 16 | done(err); 17 | } else { 18 | done(null, JSON.parse(body)); 19 | } 20 | }); 21 | }; 22 | 23 | passport.serializeUser(function (user, done) { 24 | done(null, user.id); 25 | }); 26 | 27 | passport.deserializeUser(function (id, done) { 28 | done(null, store[id]); 29 | }); 30 | 31 | passport.use(new Strategy({ 32 | authorizationURL: 'http://localhost:3000/authorize', 33 | tokenURL: 'http://localhost:3000/access_token', 34 | clientID: '12345678', 35 | clientSecret: '12345678', 36 | callbackURL: 'http://localhost:3000/auth/callback' 37 | }, function (accessToken, refreshToken, profile, cb) { 38 | store[profile.id] = profile; 39 | cb(null, profile); 40 | })); 41 | 42 | app.use(passport.initialize()); 43 | app.use(passport.session()); 44 | 45 | const router = new Router(); 46 | router.get('/client', async function (ctx) { 47 | if (ctx.isAuthenticated()) { 48 | ctx.body = ctx.state.user; 49 | } else { 50 | ctx.redirect('/auth'); 51 | } 52 | }); 53 | router.get('/auth', passport.authenticate('oauth2')); 54 | router.get('/auth/callback', async function (ctx, next) { 55 | ctx.assert(!ctx.query.error, 400, ctx.query.error); 56 | await next(); 57 | }, passport.authenticate('oauth2'), async function (ctx) { 58 | ctx.redirect('/client'); 59 | }); 60 | app.use(router.routes()); 61 | app.use(router.allowedMethods()); 62 | }; 63 | -------------------------------------------------------------------------------- /test/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | isTOTP: false 3 | }; 4 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { join } from 'path'; 4 | import chai, { expect } from 'chai'; 5 | import chaiHttp from 'chai-http'; 6 | import AuthServer from '../src'; 7 | import { generateId, totp } from '../src/util'; 8 | import Config from '../src/config'; 9 | import { decode, getCSRF } from './utils'; 10 | 11 | chai.use(chaiHttp); 12 | 13 | describe('auth-center', function () { 14 | this.timeout(0); 15 | 16 | const totp_key = generateId(); 17 | const R = Config().routes; 18 | 19 | let request; 20 | let numberCode; 21 | let isSendFail = false; 22 | 23 | before(function (done) { 24 | const authServer = AuthServer({ 25 | orm: {}, 26 | mail: { 27 | from: 'admin@example.com', 28 | name: 'minimal', 29 | version: '0.1.0', 30 | send: function (mail, callback) { 31 | const input = mail.message.createReadStream(); 32 | const chunks = []; 33 | input.on('data', function (chunk) { 34 | chunks.push(chunk); 35 | }); 36 | input.on('end', function () { 37 | const data = decode(Buffer.concat(chunks).toString()); 38 | const m2 = data.match(/([\d]+) is your/); 39 | if (m2 && m2.length > 1) { 40 | numberCode = m2[1]; 41 | } 42 | if (isSendFail) { 43 | callback(new Error('send error')); 44 | } else { 45 | callback(null, true); 46 | } 47 | }); 48 | } 49 | } 50 | }); 51 | 52 | require('./client')(authServer); 53 | 54 | request = chai.request.agent(authServer.listen(3000)); 55 | 56 | async function init () { 57 | const { 58 | sync, User, Client, Recovery, Role 59 | } = authServer.orm.database(); 60 | await sync({ 61 | force: true 62 | }); 63 | await User.add({ 64 | id: 10001, 65 | password: 'test', 66 | email: 'test@example.com', 67 | totp_key: totp_key 68 | }); 69 | await User.add({ 70 | id: 10002, 71 | password: 'admin', 72 | email: 'admin@example.com', 73 | totp_key: totp_key 74 | }); 75 | await User.update({ 76 | is_admin: true 77 | }, { 78 | where: { 79 | id: 10002 80 | } 81 | }); 82 | await Client.create({ 83 | id: '12345678', 84 | name: 'test_client', 85 | name_cn: '测试应用', 86 | secret: '12345678', 87 | redirect_uri: 'http://localhost:3000/auth/callback' 88 | }); 89 | await Recovery.create({ 90 | token: 'expired_code', 91 | user_id: 10001, 92 | createdAt: new Date(Date.now() - 360 * 1000) 93 | }); 94 | await Role.create({ 95 | user_id: 10002, 96 | client_id: '12345678', 97 | role: 'master' 98 | }); 99 | } 100 | 101 | init().then(() => { 102 | done(); 103 | }).catch((err) => { 104 | done(err); 105 | }); 106 | }); 107 | 108 | it('should config merge from file', function (done) { 109 | const config = Config(join(__dirname, '/config')); 110 | expect(config).to.have.property('isTOTP'); 111 | expect(config.isTOTP).to.be.false; 112 | expect(config).to.have.nested.property('mail.name'); 113 | done(); 114 | }); 115 | 116 | it('should i18n zh-CN', function (done) { 117 | request 118 | .get(R.login + '?locale=zh-CN') 119 | .end(function (err, res) { 120 | expect(err).to.be.null; 121 | expect(res).to.have.status(200); 122 | expect(res.text).to.match(/登录/); 123 | done(); 124 | }); 125 | }); 126 | 127 | it('should i18n default en', function (done) { 128 | request 129 | .get(R.login + '?locale=xxxxx') 130 | .end(function (err, res) { 131 | expect(err).to.be.null; 132 | expect(res).to.have.status(200); 133 | expect(res.text).to.match(/Sign In/); 134 | done(); 135 | }); 136 | }); 137 | 138 | it('should error text plain', function (done) { 139 | request 140 | .get('/404') 141 | .set('Accept', 'text/plain') 142 | .end(function (err, res) { 143 | expect(err).to.be.null; 144 | expect(res).to.have.status(404); 145 | expect(res.text).to.match(/Not Found/); 146 | done(); 147 | }); 148 | }); 149 | 150 | it('should error json type', function (done) { 151 | request 152 | .get('/404') 153 | .set('Accept', 'application/json') 154 | .end(function (err, res) { 155 | expect(err).to.be.null; 156 | expect(res).to.have.status(404); 157 | expect(res.text).to.match(/error/); 158 | expect(res.text).to.match(/Not Found/); 159 | done(); 160 | }); 161 | }); 162 | 163 | it('should redirect to authorize and login page', function (done) { 164 | request 165 | .get('/client') 166 | .end(function (err, res) { 167 | expect(err).to.be.null; 168 | expect(res).to.have.status(200); 169 | expect(res.redirects).to.have.lengthOf(3); 170 | expect(res.redirects[0]).to.match(/auth/); 171 | expect(res.redirects[1]).to.match(/authorize/); 172 | expect(res.redirects[2]).to.match(/login/); 173 | done(); 174 | }); 175 | }); 176 | 177 | it('should login => username required', function (done) { 178 | request 179 | .get(R.login) 180 | .end(function (err, res) { 181 | expect(err).to.be.null; 182 | expect(res.text).to.match(/password/); 183 | const csrf = getCSRF(res); 184 | request 185 | .post(R.session) 186 | .send({ 187 | _csrf: csrf, 188 | password: 'test' 189 | }) 190 | .end(function (err, res) { 191 | expect(err).to.be.null; 192 | expect(res).to.have.status(200); 193 | expect(res.text).to.match(/Email is required/); 194 | done(); 195 | }); 196 | }); 197 | }); 198 | 199 | it('should login => password required', function (done) { 200 | request 201 | .get(R.login) 202 | .end(function (err, res) { 203 | expect(err).to.be.null; 204 | expect(res.text).to.match(/password/); 205 | const csrf = getCSRF(res); 206 | request 207 | .post(R.session) 208 | .send({ 209 | _csrf: csrf, 210 | email: 'test@example.com' 211 | }) 212 | .end(function (err, res) { 213 | expect(err).to.be.null; 214 | expect(res).to.have.status(200); 215 | expect(res.text).to.match(/Password is required/); 216 | done(); 217 | }); 218 | }); 219 | }); 220 | 221 | it('should login => password error', function (done) { 222 | request 223 | .get(R.login) 224 | .end(function (err, res) { 225 | expect(err).to.be.null; 226 | expect(res.text).to.match(/password/); 227 | const csrf = getCSRF(res); 228 | request 229 | .post(R.session) 230 | .send({ 231 | _csrf: csrf, 232 | email: 'test@example.com', 233 | password: 'wrong', 234 | terms: 1 235 | }) 236 | .end(function (err, res) { 237 | expect(err).to.be.null; 238 | expect(res).to.have.status(200); 239 | expect(res.text).to.match(/Email or password is invalid/); 240 | done(); 241 | }); 242 | }); 243 | }); 244 | 245 | it('should login totp token required', function (done) { 246 | Config({ 247 | isTOTP: true 248 | }); 249 | request 250 | .get(R.login) 251 | .end(function (err, res) { 252 | expect(err).to.be.null; 253 | expect(res.text).to.match(/password/); 254 | const csrf = getCSRF(res); 255 | request 256 | .post(R.session) 257 | .send({ 258 | _csrf: csrf, 259 | email: 'test@example.com', 260 | password: 'test' 261 | }) 262 | .end(function (err, res) { 263 | expect(err).to.be.null; 264 | expect(res).to.have.status(200); 265 | expect(res.text).to.match(/Token is required/); 266 | done(); 267 | }); 268 | }); 269 | }); 270 | 271 | it('should login totp token invalid', function (done) { 272 | request 273 | .get(R.login) 274 | .end(function (err, res) { 275 | expect(err).to.be.null; 276 | expect(res.text).to.match(/password/); 277 | const csrf = getCSRF(res); 278 | request 279 | .post(R.session) 280 | .send({ 281 | _csrf: csrf, 282 | email: 'test@example.com', 283 | password: 'test', 284 | token: '123456', 285 | terms: 1 286 | }) 287 | .end(function (err, res) { 288 | expect(err).to.be.null; 289 | expect(res).to.have.status(200); 290 | expect(res.text).to.match(/Token is invalid/); 291 | done(); 292 | }); 293 | }); 294 | }); 295 | 296 | it('should login username or password is invalid', function (done) { 297 | request 298 | .get(R.login) 299 | .end(function (err, res) { 300 | expect(err).to.be.null; 301 | expect(res.text).to.match(/password/); 302 | const csrf = getCSRF(res); 303 | request 304 | .post(R.session) 305 | .send({ 306 | _csrf: csrf, 307 | email: 'wrong@example.com', 308 | password: 'test', 309 | token: '123456', 310 | terms: 1 311 | }) 312 | .end(function (err, res) { 313 | expect(err).to.be.null; 314 | expect(res).to.have.status(200); 315 | expect(res.text).to.match(/Email or password is invalid/); 316 | done(); 317 | }); 318 | }); 319 | }); 320 | 321 | it('should login terms required', function (done) { 322 | Config({ 323 | isTOTP: true 324 | }); 325 | request 326 | .get(R.login) 327 | .end(function (err, res) { 328 | expect(err).to.be.null; 329 | expect(res.text).to.match(/password/); 330 | const csrf = getCSRF(res); 331 | request 332 | .post(R.session) 333 | .send({ 334 | _csrf: csrf, 335 | email: 'test@example.com', 336 | password: 'test', 337 | token: totp.generate(totp_key) 338 | }) 339 | .end(function (err, res) { 340 | expect(err).to.be.null; 341 | expect(res).to.have.status(200); 342 | expect(res.text).to.match(/You should agree the terms/); 343 | done(); 344 | }); 345 | }); 346 | }); 347 | 348 | it('should login => session => home => logout', function (done) { 349 | request 350 | .get(R.home) 351 | .end(function (err, res) { 352 | expect(err).to.be.null; 353 | expect(res.text).to.match(/password/); 354 | const csrf = getCSRF(res); 355 | request 356 | .post(R.session) 357 | .send({ 358 | _csrf: csrf, 359 | email: 'test@example.com', 360 | password: 'test', 361 | token: totp.generate(totp_key), 362 | terms: 1 363 | }) 364 | .end(function (err, res) { 365 | expect(err).to.be.null; 366 | expect(res).to.have.status(200); 367 | expect(res.text).to.match(/test@example\.com/); 368 | expect(res.text).to.match(/test/); 369 | request 370 | .get(R.logout) 371 | .query({ 372 | return_to: R.login 373 | }) 374 | .end(function (err, res) { 375 | expect(err).to.be.null; 376 | expect(res).to.have.status(200); 377 | expect(res.text).to.match(/password/); 378 | done(); 379 | }); 380 | }); 381 | }); 382 | }); 383 | 384 | it('should login => sendToken => home => logout', function (done) { 385 | request.get(R.home).end(function (err, res) { 386 | expect(err).to.be.null; 387 | expect(res.text).to.match(/password/); 388 | const _csrf = getCSRF(res); 389 | request.post(R.login_token).send({ 390 | _csrf, 391 | email: 'test@example.com' 392 | }).end(function (err, res) { 393 | expect(err).to.be.null; 394 | expect(res).to.have.status(200); 395 | expect(res.body).to.have.property('code', 0); 396 | request.post(R.session).send({ 397 | _csrf, 398 | email: 'test@example.com', 399 | password: 'test', 400 | token: numberCode, 401 | terms: 1 402 | }).end(function (err, res) { 403 | expect(err).to.be.null; 404 | expect(res).to.have.status(200); 405 | expect(res.text).to.match(/test@example\.com/); 406 | expect(res.text).to.match(/test/); 407 | request.get(R.logout).query({ 408 | return_to: R.login 409 | }).end(function (err, res) { 410 | expect(err).to.be.null; 411 | expect(res).to.have.status(200); 412 | expect(res.text).to.match(/password/); 413 | done(); 414 | }); 415 | }); 416 | }); 417 | }); 418 | }); 419 | 420 | it('should password reset email error', function (done) { 421 | request 422 | .get(R.password_reset) 423 | .end(function (err, res) { 424 | expect(err).to.be.null; 425 | expect(res.text).to.match(/email/); 426 | const csrf = getCSRF(res); 427 | request 428 | .post(R.resetpwd_token) 429 | .send({ 430 | _csrf: csrf, 431 | email: 'test' 432 | }) 433 | .end(function (err, res) { 434 | expect(err).to.be.null; 435 | expect(res).to.have.status(400); 436 | expect(res.text).to.match(/Email is invalid/); 437 | done(); 438 | }); 439 | }); 440 | }); 441 | 442 | it('should password reset no user will ignore', function (done) { 443 | request 444 | .get(R.password_reset) 445 | .end(function (err, res) { 446 | expect(err).to.be.null; 447 | expect(res.text).to.match(/email/); 448 | const csrf = getCSRF(res); 449 | request 450 | .post(R.resetpwd_token) 451 | .send({ 452 | _csrf: csrf, 453 | email: 'test2@example.com' 454 | }) 455 | .set('accept', 'json') 456 | .end(function (err, res) { 457 | expect(err).to.be.null; 458 | expect(res).to.have.status(200); 459 | expect(res.body.code).to.be.equal(0); 460 | done(); 461 | }); 462 | }); 463 | }); 464 | 465 | it('should password reset: send email fail', function (done) { 466 | isSendFail = true; 467 | const request2 = chai.request.agent('http://localhost:3000'); 468 | request2 469 | .get(R.password_reset) 470 | .end(function (err, res) { 471 | expect(err).to.be.null; 472 | expect(res.text).to.match(/email/); 473 | const csrf = getCSRF(res); 474 | request2 475 | .post(R.resetpwd_token) 476 | .send({ 477 | _csrf: csrf, 478 | email: 'test@example.com' 479 | }) 480 | .end(function (err, res) { 481 | expect(err).to.be.null; 482 | expect(res).to.have.status(500); 483 | expect(res.text).to.match(/Send failed/); 484 | isSendFail = false; 485 | done(); 486 | }); 487 | }); 488 | }); 489 | 490 | it('should password reset', function (done) { 491 | const request2 = chai.request.agent('http://localhost:3000'); 492 | request2 493 | .get(R.password_reset) 494 | .end(function (err, res) { 495 | expect(err).to.be.null; 496 | expect(res.text).to.match(/email/); 497 | const csrf = getCSRF(res); 498 | request2 499 | .post(R.resetpwd_token) 500 | .send({ 501 | _csrf: csrf, 502 | email: 'test@example.com' 503 | }) 504 | .end(function (err, res) { 505 | expect(err).to.be.null; 506 | expect(res).to.have.status(200); 507 | expect(res.body.code).to.be.equal(0); 508 | done(); 509 | }); 510 | }); 511 | }); 512 | 513 | it('should password change page: email required', function (done) { 514 | request 515 | .get(R.password_change) 516 | .end(function (err, res) { 517 | expect(err).to.be.null; 518 | expect(res.text).to.match(/Email is required/); 519 | done(); 520 | }); 521 | }); 522 | 523 | it('should password change page: captcha required', function (done) { 524 | request 525 | .get(R.password_change) 526 | .query({ 527 | email: 'test@example.com' 528 | }) 529 | .end(function (err, res) { 530 | expect(err).to.be.null; 531 | expect(res.text).to.match(/Captcha is required/); 532 | done(); 533 | }); 534 | }); 535 | 536 | it('should password change page: email invalid', function (done) { 537 | request 538 | .get(R.password_change) 539 | .query({ 540 | email: 'wrong@example.com', 541 | token: 'wrong' 542 | }) 543 | .end(function (err, res) { 544 | expect(err).to.be.null; 545 | expect(res.text).to.match(/Email is invalid/); 546 | done(); 547 | }); 548 | }); 549 | 550 | it('should password change page: captcha invalid 1', function (done) { 551 | request 552 | .get(R.password_change) 553 | .query({ 554 | email: 'test@example.com', 555 | token: 'wrong' 556 | }) 557 | .end(function (err, res) { 558 | expect(err).to.be.null; 559 | expect(res.text).to.match(/Captcha is invalid/); 560 | done(); 561 | }); 562 | }); 563 | 564 | it('should password change page: captcha invalid 2', function (done) { 565 | request 566 | .get(R.password_change) 567 | .query({ 568 | email: 'test@example.com', 569 | token: 'expired_code' 570 | }) 571 | .end(function (err, res) { 572 | expect(err).to.be.null; 573 | expect(res.text).to.match(/Captcha is invalid/); 574 | done(); 575 | }); 576 | }); 577 | 578 | it('should password change page', function (done) { 579 | request 580 | .get(R.password_change) 581 | .query({ 582 | email: 'test@example.com', 583 | token: numberCode 584 | }) 585 | .end(function (err, res) { 586 | expect(err).to.be.null; 587 | expect(res).to.have.status(200); 588 | expect(res.text).to.match(/Change password/); 589 | done(); 590 | }); 591 | }); 592 | 593 | it('should password change: Password is required', function (done) { 594 | request 595 | .get(R.password_change) 596 | .query({ 597 | email: 'test@example.com', 598 | token: numberCode 599 | }) 600 | .end(function (err, res) { 601 | expect(err).to.be.null; 602 | expect(res.text).to.match(/password2/); 603 | const csrf = getCSRF(res); 604 | request 605 | .post(R.password_change) 606 | .send({ 607 | _csrf: csrf, 608 | email: 'test@example.com', 609 | token: numberCode 610 | }) 611 | .end(function (err, res) { 612 | expect(err).to.be.null; 613 | expect(res.text).to.match(/Password is required/); 614 | done(); 615 | }); 616 | }); 617 | }); 618 | 619 | it('should password change: Passwords do not match', function (done) { 620 | request 621 | .get(R.password_change) 622 | .query({ 623 | email: 'test@example.com', 624 | token: numberCode 625 | }) 626 | .end(function (err, res) { 627 | expect(err).to.be.null; 628 | expect(res.text).to.match(/password2/); 629 | const csrf = getCSRF(res); 630 | request 631 | .post(R.password_change) 632 | .send({ 633 | _csrf: csrf, 634 | email: 'test@example.com', 635 | token: numberCode, 636 | password: 'nomatch1', 637 | password2: 'nomatch2' 638 | }) 639 | .end(function (err, res) { 640 | expect(err).to.be.null; 641 | expect(res.text).to.match(/Passwords do not match/); 642 | done(); 643 | }); 644 | }); 645 | }); 646 | 647 | it('should password change: Password length atleast 8', function (done) { 648 | request 649 | .get(R.password_change) 650 | .query({ 651 | email: 'test@example.com', 652 | token: numberCode 653 | }) 654 | .end(function (err, res) { 655 | expect(err).to.be.null; 656 | expect(res.text).to.match(/password2/); 657 | const csrf = getCSRF(res); 658 | request 659 | .post(R.password_change) 660 | .send({ 661 | _csrf: csrf, 662 | email: 'test@example.com', 663 | token: numberCode, 664 | password: 'short', 665 | password2: 'short' 666 | }) 667 | .end(function (err, res) { 668 | expect(err).to.be.null; 669 | expect(res.text).to.match(/Password length atleast 8/); 670 | done(); 671 | }); 672 | }); 673 | }); 674 | 675 | it('should password change', function (done) { 676 | request 677 | .get(R.password_change) 678 | .query({ 679 | email: 'test@example.com', 680 | token: numberCode 681 | }) 682 | .end(function (err, res) { 683 | expect(err).to.be.null; 684 | expect(res.text).to.match(/password2/); 685 | const csrf = getCSRF(res); 686 | request 687 | .post(R.password_change) 688 | .send({ 689 | _csrf: csrf, 690 | email: 'test@example.com', 691 | token: numberCode, 692 | password: 'testnewpwd', 693 | password2: 'testnewpwd' 694 | }) 695 | .end(function (err, res) { 696 | expect(err).to.be.null; 697 | expect(res).to.have.status(200); 698 | expect(res.text).to.match(/email/); 699 | expect(res.text).to.match(/password/); 700 | expect(res.text).to.match(/Password have been changed/); 701 | done(); 702 | }); 703 | }); 704 | }); 705 | 706 | it('should password change page: Captcha is invalid 3', function (done) { 707 | request 708 | .get(R.password_change) 709 | .query({ 710 | email: 'test@example.com', 711 | token: 'expired_code' 712 | }) 713 | .end(function (err, res) { 714 | expect(err).to.be.null; 715 | expect(res.text).to.match(/Captcha is invalid/); 716 | done(); 717 | }); 718 | }); 719 | 720 | it('should password change page: Try again in a minute', function (done) { 721 | request 722 | .get(R.password_change) 723 | .query({ 724 | email: 'test@example.com', 725 | token: 'expired_code' 726 | }) 727 | .end(function (err, res) { 728 | expect(err).to.be.null; 729 | expect(res.text).to.match(/Try again in a minute/); 730 | done(); 731 | }); 732 | }); 733 | 734 | it('should authorize => login => session => client', function (done) { 735 | Config({ 736 | isTOTP: false 737 | }); 738 | request 739 | .get(R.authorize) 740 | .query({ 741 | response_type: 'code', 742 | client_id: '12345678', 743 | redirect_uri: 'http://localhost:3000/auth/callback' 744 | }) 745 | .end(function (err, res) { 746 | expect(err).to.be.null; 747 | expect(res.text).to.match(/password/); 748 | const csrf = getCSRF(res); 749 | request 750 | .post(R.session) 751 | .send({ 752 | _csrf: csrf, 753 | email: 'test@example.com', 754 | password: 'testnewpwd', 755 | terms: 1 756 | }) 757 | .end(function (err, res) { 758 | expect(err).to.be.null; 759 | expect(res).to.have.status(200); 760 | expect(res.redirects).to.have.lengthOf(3); 761 | expect(res.text).to.match(/email/); 762 | expect(res.text).to.match(/test/); 763 | done(); 764 | }); 765 | }); 766 | }); 767 | 768 | it('should error: response_type is missing', function (done) { 769 | request 770 | .get(R.authorize) 771 | .query({ 772 | client_id: '12345678' 773 | }) 774 | .end(function (err, res) { 775 | expect(err).to.be.null; 776 | expect(res).to.have.status(400); 777 | expect(res.redirects).to.have.lengthOf(1); 778 | expect(res.redirects[0]).to.match(/error=invalid_request/); 779 | done(); 780 | }); 781 | }); 782 | 783 | it('should error: response_type unsupported', function (done) { 784 | request 785 | .get(R.authorize) 786 | .query({ 787 | response_type: 'unsupported', 788 | client_id: '12345678' 789 | }) 790 | .end(function (err, res) { 791 | expect(err).to.be.null; 792 | expect(res).to.have.status(400); 793 | expect(res.redirects).to.have.lengthOf(1); 794 | expect(res.redirects[0]).to.match(/error=unsupported_response_type/); 795 | done(); 796 | }); 797 | }); 798 | 799 | it('should authorize => client', function (done) { 800 | request 801 | .get(R.authorize) 802 | .query({ 803 | response_type: 'code', 804 | client_id: '12345678' 805 | }) 806 | .end(function (err, res) { 807 | expect(err).to.be.null; 808 | expect(res).to.have.status(200); 809 | expect(res.text).to.match(/email/); 810 | expect(res.text).to.match(/test/); 811 | done(); 812 | }); 813 | }); 814 | 815 | it('should return redirect_uri is invalid', function (done) { 816 | request 817 | .get(R.authorize) 818 | .query({ 819 | response_type: 'code', 820 | client_id: '12345678', 821 | redirect_uri: 'http://localhost:3000/invalid' 822 | }) 823 | .end(function (err, res) { 824 | expect(err).to.be.null; 825 | expect(res).to.have.status(400); 826 | expect(res.text).to.match(/redirect_uri is invalid/); 827 | done(); 828 | }); 829 | }); 830 | 831 | it('should users => home => logout', function (done) { 832 | request.get(R.admin.users).end(function (err, res) { 833 | expect(err).to.be.null; 834 | expect(res.text).to.match(/test@example\.com/); 835 | expect(res.text).to.match(/test/); 836 | request 837 | .get(R.logout) 838 | .end(function (err, res) { 839 | expect(err).to.be.null; 840 | done(); 841 | }); 842 | }); 843 | }); 844 | 845 | it('should login => home', function (done) { 846 | request.get(R.home).end(function (err, res) { 847 | expect(err).to.be.null; 848 | expect(res.text).to.match(/password/); 849 | const csrf = getCSRF(res); 850 | request 851 | .post(R.session) 852 | .send({ 853 | _csrf: csrf, 854 | email: 'admin@example.com', 855 | password: 'admin', 856 | token: totp.generate(totp_key), 857 | terms: 1 858 | }) 859 | .end(function (err, res) { 860 | expect(err).to.be.null; 861 | expect(res).to.have.status(200); 862 | expect(res.text).to.match(/admin@example\.com/); 863 | expect(res.text).to.match(/href="\/admin"/); 864 | done(); 865 | }); 866 | }); 867 | }); 868 | 869 | it('should show security page: Password is required', function (done) { 870 | request.get(R.security).end(function (err, res) { 871 | expect(err).to.be.null; 872 | expect(res).to.have.status(200); 873 | expect(res.text).to.match(/Change Password/); 874 | const csrf = getCSRF(res); 875 | request 876 | .post(R.security_change) 877 | .send({ 878 | _csrf: csrf 879 | }) 880 | .end(function (err, res) { 881 | expect(err).to.be.null; 882 | expect(res.text).to.match(/Password is required/); 883 | done(); 884 | }); 885 | }); 886 | }); 887 | 888 | it('should show security page: Old and new password can not be the same', function (done) { 889 | request.get(R.security).end(function (err, res) { 890 | expect(err).to.be.null; 891 | expect(res).to.have.status(200); 892 | expect(res.text).to.match(/Change Password/); 893 | const csrf = getCSRF(res); 894 | request 895 | .post(R.security_change) 896 | .send({ 897 | _csrf: csrf, 898 | oldpwd: 'admin', 899 | newpwd: 'admin', 900 | renewpwd: 'admin' 901 | }) 902 | .end(function (err, res) { 903 | expect(err).to.be.null; 904 | expect(res.text).to.match(/Old and new password can not be the same/); 905 | done(); 906 | }); 907 | }); 908 | }); 909 | 910 | it('should show security page: Passwords do not match', function (done) { 911 | request.get(R.security).end(function (err, res) { 912 | expect(err).to.be.null; 913 | expect(res).to.have.status(200); 914 | expect(res.text).to.match(/Change Password/); 915 | const csrf = getCSRF(res); 916 | request 917 | .post(R.security_change) 918 | .send({ 919 | _csrf: csrf, 920 | oldpwd: 'admin', 921 | newpwd: 'nomatch1', 922 | renewpwd: 'nomatch2' 923 | }) 924 | .end(function (err, res) { 925 | expect(err).to.be.null; 926 | expect(res.text).to.match(/Passwords do not match/); 927 | done(); 928 | }); 929 | }); 930 | }); 931 | 932 | it('should show security page: Password length atleast 8', function (done) { 933 | request.get(R.security).end(function (err, res) { 934 | expect(err).to.be.null; 935 | expect(res).to.have.status(200); 936 | expect(res.text).to.match(/Change Password/); 937 | const csrf = getCSRF(res); 938 | request 939 | .post(R.security_change) 940 | .send({ 941 | _csrf: csrf, 942 | oldpwd: 'admin', 943 | newpwd: 'short', 944 | renewpwd: 'short' 945 | }) 946 | .end(function (err, res) { 947 | expect(err).to.be.null; 948 | expect(res.text).to.match(/Password length atleast 8/); 949 | done(); 950 | }); 951 | }); 952 | }); 953 | 954 | it('should show security page: Old password is invalid', function (done) { 955 | request.get(R.security).end(function (err, res) { 956 | expect(err).to.be.null; 957 | expect(res).to.have.status(200); 958 | expect(res.text).to.match(/Change Password/); 959 | const csrf = getCSRF(res); 960 | request 961 | .post(R.security_change) 962 | .send({ 963 | _csrf: csrf, 964 | oldpwd: 'wrong', 965 | newpwd: 'newpassword', 966 | renewpwd: 'newpassword' 967 | }) 968 | .end(function (err, res) { 969 | expect(err).to.be.null; 970 | expect(res.text).to.match(/Old password is invalid/); 971 | done(); 972 | }); 973 | }); 974 | }); 975 | 976 | it('should show security page: done', function (done) { 977 | request.get(R.security).end(function (err, res) { 978 | expect(err).to.be.null; 979 | expect(res).to.have.status(200); 980 | expect(res.text).to.match(/Change Password/); 981 | const csrf = getCSRF(res); 982 | request 983 | .post(R.security_change) 984 | .send({ 985 | _csrf: csrf, 986 | oldpwd: 'admin', 987 | newpwd: 'newpassword', 988 | renewpwd: 'newpassword' 989 | }) 990 | .end(function (err, res) { 991 | expect(err).to.be.null; 992 | expect(res.text).to.match(/Password have been changed/); 993 | done(); 994 | }); 995 | }); 996 | }); 997 | 998 | it('should login => users', function (done) { 999 | request.get(R.admin.users).end(function (err, res) { 1000 | expect(err).to.be.null; 1001 | expect(res.text).to.match(/password/); 1002 | const csrf = getCSRF(res); 1003 | request 1004 | .post(R.session) 1005 | .send({ 1006 | _csrf: csrf, 1007 | email: 'admin@example.com', 1008 | password: 'newpassword', 1009 | token: totp.generate(totp_key), 1010 | terms: 1 1011 | }) 1012 | .end(function (err, res) { 1013 | expect(err).to.be.null; 1014 | expect(res).to.have.status(200); 1015 | expect(res.text).to.match(/Email/); 1016 | expect(res.text).to.match(/Key/); 1017 | expect(res.text).to.match(/Updated At/); 1018 | done(); 1019 | }); 1020 | }); 1021 | }); 1022 | 1023 | it('should login redirect home', function (done) { 1024 | request.get(R.login).end(function (err, res) { 1025 | expect(err).to.be.null; 1026 | expect(res).to.have.status(200); 1027 | expect(res.redirects).to.include('http://127.0.0.1:3000/'); 1028 | expect(res.text).to.match(/admin@example.com/); 1029 | done(); 1030 | }); 1031 | }); 1032 | 1033 | it('should user list', function (done) { 1034 | request.get(R.admin.users).end(function (err, res) { 1035 | expect(err).to.be.null; 1036 | expect(res).to.have.status(200); 1037 | expect(res.text).to.match(/test@example\.com/); 1038 | expect(res.text).to.match(/admin@example\.com/); 1039 | done(); 1040 | }); 1041 | }); 1042 | 1043 | it('should user list with query', function (done) { 1044 | request.get(R.admin.users + '?q=admin').end(function (err, res) { 1045 | expect(err).to.be.null; 1046 | expect(res).to.have.status(200); 1047 | expect(res.text).to.not.match(/test@example\.com/); 1048 | expect(res.text).to.match(/admin@example\.com/); 1049 | done(); 1050 | }); 1051 | }); 1052 | 1053 | it('should search user list with query', function (done) { 1054 | request.get(R.admin.users).end(function (err, res) { 1055 | expect(err).to.be.null; 1056 | expect(res).to.have.status(200); 1057 | const _csrf = getCSRF(res); 1058 | request.post(R.admin.search_user) 1059 | .send({ q: 'test', _csrf }) 1060 | .end(function (err, res) { 1061 | expect(err).to.be.null; 1062 | expect(res).to.have.status(200); 1063 | expect(res.body).to.include('test@example.com'); 1064 | done(); 1065 | }); 1066 | }); 1067 | }); 1068 | 1069 | it('should send totp: ID is required', function (done) { 1070 | request.get(R.admin.users).end(function (err, res) { 1071 | expect(err).to.be.null; 1072 | expect(res).to.have.status(200); 1073 | const csrf = getCSRF(res); 1074 | request 1075 | .post(R.admin.send_totp) 1076 | .send({ 1077 | _csrf: csrf 1078 | }) 1079 | .end(function (err, res) { 1080 | expect(err).to.be.null; 1081 | expect(res.text).to.match(/ID is required/); 1082 | done(); 1083 | }); 1084 | }); 1085 | }); 1086 | 1087 | it('should send totp: user not found', function (done) { 1088 | request.get(R.admin.users).end(function (err, res) { 1089 | expect(err).to.be.null; 1090 | expect(res).to.have.status(200); 1091 | const csrf = getCSRF(res); 1092 | request 1093 | .post(R.admin.send_totp) 1094 | .send({ 1095 | _csrf: csrf, 1096 | id: 'wrong' 1097 | }) 1098 | .end(function (err, res) { 1099 | expect(err).to.be.null; 1100 | expect(res.text).to.match(/Update failed/); 1101 | done(); 1102 | }); 1103 | }); 1104 | }); 1105 | 1106 | it('should send totp: send failed', function (done) { 1107 | request.get(R.admin.users).end(function (err, res) { 1108 | expect(err).to.be.null; 1109 | expect(res).to.have.status(200); 1110 | const csrf = getCSRF(res); 1111 | isSendFail = true; 1112 | request 1113 | .post(R.admin.send_totp) 1114 | .send({ 1115 | _csrf: csrf, 1116 | id: 10001 1117 | }) 1118 | .end(function (err, res) { 1119 | expect(err).to.be.null; 1120 | expect(res.text).to.match(/Reset and send key failed/); 1121 | isSendFail = false; 1122 | done(); 1123 | }); 1124 | }); 1125 | }); 1126 | 1127 | it('should send totp', function (done) { 1128 | request.get(R.admin.users).end(function (err, res) { 1129 | expect(err).to.be.null; 1130 | expect(res).to.have.status(200); 1131 | const csrf = getCSRF(res); 1132 | request 1133 | .post(R.admin.send_totp) 1134 | .send({ 1135 | _csrf: csrf, 1136 | id: 10001 1137 | }) 1138 | .end(function (err, res) { 1139 | expect(err).to.be.null; 1140 | expect(res.text).to.match(/successfully/); 1141 | done(); 1142 | }); 1143 | }); 1144 | }); 1145 | 1146 | it('should add client: Name is required', function (done) { 1147 | request.get(R.admin.clients).end(function (err, res) { 1148 | expect(err).to.be.null; 1149 | expect(res).to.have.status(200); 1150 | const csrf = getCSRF(res); 1151 | request 1152 | .post(R.admin.add_client) 1153 | .send({ 1154 | _csrf: csrf 1155 | }) 1156 | .end(function (err, res) { 1157 | expect(err).to.be.null; 1158 | expect(res.text).to.match(/Name is required/); 1159 | done(); 1160 | }); 1161 | }); 1162 | }); 1163 | 1164 | it('should add client: Name CN is required', function (done) { 1165 | request.get(R.admin.clients).end(function (err, res) { 1166 | expect(err).to.be.null; 1167 | expect(res).to.have.status(200); 1168 | const csrf = getCSRF(res); 1169 | request 1170 | .post(R.admin.add_client) 1171 | .send({ 1172 | _csrf: csrf, 1173 | name: 'client1' 1174 | }) 1175 | .end(function (err, res) { 1176 | expect(err).to.be.null; 1177 | expect(res.text).to.match(/Name CN is required/); 1178 | done(); 1179 | }); 1180 | }); 1181 | }); 1182 | 1183 | it('should add client: Redirect URI is required', function (done) { 1184 | request.get(R.admin.clients).end(function (err, res) { 1185 | expect(err).to.be.null; 1186 | expect(res).to.have.status(200); 1187 | const csrf = getCSRF(res); 1188 | request 1189 | .post(R.admin.add_client) 1190 | .send({ 1191 | _csrf: csrf, 1192 | name: 'client1', 1193 | name_cn: '应用1' 1194 | }) 1195 | .end(function (err, res) { 1196 | expect(err).to.be.null; 1197 | expect(res.text).to.match(/Redirect URI is required/); 1198 | done(); 1199 | }); 1200 | }); 1201 | }); 1202 | 1203 | it('should add client failed', function (done) { 1204 | request.get(R.admin.clients).end(function (err, res) { 1205 | expect(err).to.be.null; 1206 | expect(res).to.have.status(200); 1207 | const csrf = getCSRF(res); 1208 | request 1209 | .post(R.admin.add_client) 1210 | .send({ 1211 | _csrf: csrf, 1212 | name: [1, 2, 3], 1213 | name_cn: '应用1', 1214 | redirect_uri: 'http://localhost' 1215 | }) 1216 | .end(function (err, res) { 1217 | expect(err).to.be.null; 1218 | expect(res.text).to.match(/Add new client failed/); 1219 | done(); 1220 | }); 1221 | }); 1222 | }); 1223 | 1224 | it('should add client', function (done) { 1225 | request.get(R.admin.clients).end(function (err, res) { 1226 | expect(err).to.be.null; 1227 | expect(res).to.have.status(200); 1228 | const csrf = getCSRF(res); 1229 | request 1230 | .post(R.admin.add_client) 1231 | .send({ 1232 | _csrf: csrf, 1233 | name: 'client1', 1234 | name_cn: '应用1', 1235 | redirect_uri: 'http://localhost' 1236 | }) 1237 | .end(function (err, res) { 1238 | expect(err).to.be.null; 1239 | expect(res.text).to.match(/Add new client successfully/); 1240 | done(); 1241 | }); 1242 | }); 1243 | }); 1244 | 1245 | it('should generate secret: ID is required', function (done) { 1246 | request.get(R.admin.clients).end(function (err, res) { 1247 | expect(err).to.be.null; 1248 | expect(res).to.have.status(200); 1249 | const csrf = getCSRF(res); 1250 | request 1251 | .post(R.admin.generate_secret) 1252 | .send({ 1253 | _csrf: csrf 1254 | }) 1255 | .end(function (err, res) { 1256 | expect(err).to.be.null; 1257 | expect(res.text).to.match(/ID is required/); 1258 | done(); 1259 | }); 1260 | }); 1261 | }); 1262 | 1263 | it('should generate secret: client not found', function (done) { 1264 | request.get(R.admin.clients).end(function (err, res) { 1265 | expect(err).to.be.null; 1266 | expect(res).to.have.status(200); 1267 | const csrf = getCSRF(res); 1268 | request 1269 | .post(R.admin.generate_secret) 1270 | .send({ 1271 | _csrf: csrf, 1272 | id: 'client2' 1273 | }) 1274 | .end(function (err, res) { 1275 | expect(err).to.be.null; 1276 | expect(res.text).to.match(/Update failed/); 1277 | done(); 1278 | }); 1279 | }); 1280 | }); 1281 | 1282 | it('should generate secret failed', function (done) { 1283 | request.get(R.admin.clients).end(function (err, res) { 1284 | expect(err).to.be.null; 1285 | expect(res).to.have.status(200); 1286 | const csrf = getCSRF(res); 1287 | request 1288 | .post(R.admin.generate_secret) 1289 | .send({ 1290 | _csrf: csrf, 1291 | id: { 1292 | $or: 123 1293 | } 1294 | }) 1295 | .end(function (err, res) { 1296 | expect(err).to.be.null; 1297 | expect(res.text).to.match(/Generate new secret failed/); 1298 | done(); 1299 | }); 1300 | }); 1301 | }); 1302 | 1303 | it('should generate secret', function (done) { 1304 | request.get(R.admin.clients).end(function (err, res) { 1305 | expect(err).to.be.null; 1306 | expect(res).to.have.status(200); 1307 | const csrf = getCSRF(res); 1308 | request 1309 | .post(R.admin.generate_secret) 1310 | .send({ 1311 | _csrf: csrf, 1312 | id: '12345678' 1313 | }) 1314 | .end(function (err, res) { 1315 | expect(err).to.be.null; 1316 | expect(res.text).to.match(/Generate new secret successfully/); 1317 | done(); 1318 | }); 1319 | }); 1320 | }); 1321 | 1322 | it('should client list', function (done) { 1323 | request 1324 | .get(R.admin.clients) 1325 | .end(function (err, res) { 1326 | expect(err).to.be.null; 1327 | expect(res.text).to.match(/test_client/); 1328 | done(); 1329 | }); 1330 | }); 1331 | 1332 | it('should client list with query', function (done) { 1333 | request 1334 | .get(R.admin.clients + '?q=notfound') 1335 | .end(function (err, res) { 1336 | expect(err).to.be.null; 1337 | expect(res.text).to.match(/No data available in table/); 1338 | done(); 1339 | }); 1340 | }); 1341 | 1342 | it('should role list search', function (done) { 1343 | request.get(R.admin.roles + '?q=test').end(function (err, res) { 1344 | expect(err).to.be.null; 1345 | expect(res).to.have.status(200); 1346 | expect(res.text).to.match(/No data available in table/); 1347 | done(); 1348 | }); 1349 | }); 1350 | 1351 | it('should add role: user is required', function (done) { 1352 | request.get(R.admin.roles).end(function (err, res) { 1353 | expect(err).to.be.null; 1354 | expect(res).to.have.status(200); 1355 | const csrf = getCSRF(res); 1356 | request 1357 | .post(R.admin.add_role) 1358 | .send({ 1359 | _csrf: csrf 1360 | }) 1361 | .end(function (err, res) { 1362 | expect(err).to.be.null; 1363 | expect(res.text).to.match(/Email is required/); 1364 | done(); 1365 | }); 1366 | }); 1367 | }); 1368 | 1369 | it('should add role: client is required', function (done) { 1370 | request.get(R.admin.roles).end(function (err, res) { 1371 | expect(err).to.be.null; 1372 | expect(res).to.have.status(200); 1373 | const csrf = getCSRF(res); 1374 | request 1375 | .post(R.admin.add_role) 1376 | .send({ 1377 | _csrf: csrf, 1378 | email: 'test@example.com' 1379 | }) 1380 | .end(function (err, res) { 1381 | expect(err).to.be.null; 1382 | expect(res.text).to.match(/Client is required/); 1383 | done(); 1384 | }); 1385 | }); 1386 | }); 1387 | 1388 | it('should add role: role is required', function (done) { 1389 | request.get(R.admin.roles).end(function (err, res) { 1390 | expect(err).to.be.null; 1391 | expect(res).to.have.status(200); 1392 | const csrf = getCSRF(res); 1393 | request 1394 | .post(R.admin.add_role) 1395 | .send({ 1396 | _csrf: csrf, 1397 | email: 'test@example.com', 1398 | client: 12345678 1399 | }) 1400 | .end(function (err, res) { 1401 | expect(err).to.be.null; 1402 | expect(res.text).to.match(/Role is required/); 1403 | done(); 1404 | }); 1405 | }); 1406 | }); 1407 | 1408 | it('should add role: User is not existed', function (done) { 1409 | request.get(R.admin.roles).end(function (err, res) { 1410 | expect(err).to.be.null; 1411 | expect(res).to.have.status(200); 1412 | const csrf = getCSRF(res); 1413 | request 1414 | .post(R.admin.add_role) 1415 | .send({ 1416 | _csrf: csrf, 1417 | email: 'notfound@example.com', 1418 | client: 12345678, 1419 | role: 'test' 1420 | }) 1421 | .end(function (err, res) { 1422 | expect(err).to.be.null; 1423 | expect(res.text).to.match(/User is not existed/); 1424 | done(); 1425 | }); 1426 | }); 1427 | }); 1428 | 1429 | it('should add role: success', function (done) { 1430 | request.get(R.admin.roles).end(function (err, res) { 1431 | expect(err).to.be.null; 1432 | expect(res).to.have.status(200); 1433 | const csrf = getCSRF(res); 1434 | request 1435 | .post(R.admin.add_role) 1436 | .send({ 1437 | _csrf: csrf, 1438 | email: 'test@example.com', 1439 | client: 12345678, 1440 | role: 'test' 1441 | }) 1442 | .end(function (err, res) { 1443 | expect(err).to.be.null; 1444 | expect(res.text).to.match(/successfully/); 1445 | done(); 1446 | }); 1447 | }); 1448 | }); 1449 | 1450 | it('should home page app list', function (done) { 1451 | request.get(R.home).end(function (err, res) { 1452 | expect(err).to.be.null; 1453 | expect(res).to.have.status(200); 1454 | expect(res.text).to.match(/test_client/); 1455 | done(); 1456 | }); 1457 | }); 1458 | 1459 | it('should role list search', function (done) { 1460 | request.get(R.admin.roles + '?q=test').end(function (err, res) { 1461 | expect(err).to.be.null; 1462 | expect(res).to.have.status(200); 1463 | expect(res.text).to.match(/test@example\.com/); 1464 | done(); 1465 | }); 1466 | }); 1467 | 1468 | it('should add role: existed', function (done) { 1469 | request.get(R.admin.roles).end(function (err, res) { 1470 | expect(err).to.be.null; 1471 | expect(res).to.have.status(200); 1472 | const csrf = getCSRF(res); 1473 | request 1474 | .post(R.admin.add_role) 1475 | .send({ 1476 | _csrf: csrf, 1477 | email: 'test@example.com', 1478 | client: 12345678, 1479 | role: 'test' 1480 | }) 1481 | .end(function (err, res) { 1482 | expect(err).to.be.null; 1483 | expect(res.text).to.match(/existed/); 1484 | done(); 1485 | }); 1486 | }); 1487 | }); 1488 | 1489 | it('should delete role: id is required', function (done) { 1490 | request.get(R.admin.roles).end(function (err, res) { 1491 | expect(err).to.be.null; 1492 | expect(res).to.have.status(200); 1493 | const csrf = getCSRF(res); 1494 | request 1495 | .post(R.admin.delete_role) 1496 | .send({ 1497 | _csrf: csrf 1498 | }) 1499 | .end(function (err, res) { 1500 | expect(err).to.be.null; 1501 | expect(res.text).to.match(/Id is required/); 1502 | done(); 1503 | }); 1504 | }); 1505 | }); 1506 | 1507 | it('should delete role: success & existed', function (done) { 1508 | request.get(R.admin.roles).end(function (err, res) { 1509 | expect(err).to.be.null; 1510 | expect(res).to.have.status(200); 1511 | const id = res.text.match(/ { 105 | done(); 106 | }).catch((err) => { 107 | done(err); 108 | }); 109 | }); 110 | 111 | it('should error: unsupported grant type', function (done) { 112 | request 113 | .post(R.access_token) 114 | .type('form') 115 | .set('Accept', 'application/json') 116 | .send({ 117 | client_id, 118 | client_secret, 119 | grant_type: 'password' 120 | }) 121 | .end(function (err, res) { 122 | expect(err).to.be.null; 123 | expect(res).to.have.status(400); 124 | expect(res.text).to.match(/unsupported grant type/); 125 | done(); 126 | }); 127 | }); 128 | 129 | it('should error: refresh_token is missing', function (done) { 130 | request 131 | .post(R.access_token) 132 | .type('form') 133 | .set('Accept', 'application/json') 134 | .send({ 135 | client_id, 136 | client_secret, 137 | grant_type: 'refresh_token' 138 | }) 139 | .end(function (err, res) { 140 | expect(err).to.be.null; 141 | expect(res).to.have.status(400); 142 | expect(res.text).to.match(/refresh_token is missing/); 143 | done(); 144 | }); 145 | }); 146 | 147 | it('should error: refresh_token is invalid', function (done) { 148 | request 149 | .post(R.access_token) 150 | .type('form') 151 | .set('Accept', 'application/json') 152 | .send({ 153 | client_id, 154 | client_secret, 155 | grant_type: 'refresh_token', 156 | refresh_token: 'invalid' 157 | }) 158 | .end(function (err, res) { 159 | expect(err).to.be.null; 160 | expect(res).to.have.status(400); 161 | expect(res.text).to.match(/refresh_token is invalid/); 162 | done(); 163 | }); 164 | }); 165 | 166 | it('should error: refresh_token is expired', function (done) { 167 | request 168 | .post(R.access_token) 169 | .type('form') 170 | .set('Accept', 'application/json') 171 | .send({ 172 | client_id, 173 | client_secret, 174 | grant_type: 'refresh_token', 175 | refresh_token: 'expired' 176 | }) 177 | .end(function (err, res) { 178 | expect(err).to.be.null; 179 | expect(res).to.have.status(400); 180 | expect(res.text).to.match(/refresh_token has expired/); 181 | done(); 182 | }); 183 | }); 184 | 185 | it('should refresh the token', function (done) { 186 | request 187 | .post(R.access_token) 188 | .type('form') 189 | .set('Accept', 'application/json') 190 | .send({ 191 | client_id, 192 | client_secret, 193 | refresh_token, 194 | grant_type: 'refresh_token' 195 | }) 196 | .end(function (err, res) { 197 | expect(err).to.be.null; 198 | expect(res).to.have.status(200); 199 | expect(res.body).to.have.property('access_token'); 200 | expect(res.body).to.have.property('refresh_token', refresh_token); 201 | expect(res.body).to.have.property('expires_in'); 202 | expect(res.body).to.have.property('token_type', 'bearer'); 203 | access_token = res.body.access_token; 204 | done(); 205 | }); 206 | }); 207 | 208 | it('should get qrcode renew', function (done) { 209 | request 210 | .get(R.login) 211 | .end(function (err, res) { 212 | expect(err).to.be.null; 213 | expect(res.text).to.match(/password/); 214 | const csrf = getCSRF(res); 215 | request 216 | .post(R.qrcode) 217 | .type('form') 218 | .set('Accept', 'application/json') 219 | .send({ 220 | _csrf: csrf, 221 | renew: 1 222 | }) 223 | .end(function (err, res) { 224 | expect(err).to.be.null; 225 | expect(res).to.have.status(200); 226 | expect(res.body).to.have.property('code'); 227 | expect(res.body).to.have.property('status', 1); 228 | code = res.body.code.id; 229 | done(); 230 | }); 231 | }); 232 | }); 233 | 234 | it('should get qrcode', function (done) { 235 | request 236 | .get(R.login) 237 | .end(function (err, res) { 238 | expect(err).to.be.null; 239 | expect(res.text).to.match(/password/); 240 | const csrf = getCSRF(res); 241 | request 242 | .post(R.qrcode) 243 | .type('form') 244 | .set('Accept', 'application/json') 245 | .send({ 246 | _csrf: csrf 247 | }) 248 | .end(function (err, res) { 249 | expect(err).to.be.null; 250 | expect(res).to.have.status(200); 251 | expect(res.body).to.have.property('status', 2); 252 | done(); 253 | }); 254 | }); 255 | }); 256 | 257 | it('should scan login error', function (done) { 258 | request 259 | .post(R.scan_login) 260 | .type('form') 261 | .set('Accept', 'application/json') 262 | .set('Authorization', `bearer ${access_token}`) 263 | .send({ 264 | code, 265 | token: 'wrong' 266 | }) 267 | .end(function (err, res) { 268 | expect(err).to.be.null; 269 | expect(res).to.have.status(400); 270 | expect(res.body).to.have.property('message', 'TOTP code invalid'); 271 | done(); 272 | }); 273 | }); 274 | 275 | it('should scan login', function (done) { 276 | request 277 | .post(R.scan_login) 278 | .type('form') 279 | .set('Accept', 'application/json') 280 | .set('Authorization', `bearer ${access_token}`) 281 | .send({ 282 | code, 283 | token: totp.generate(totp_key) 284 | }) 285 | .end(function (err, res) { 286 | expect(err).to.be.null; 287 | expect(res).to.have.status(200); 288 | expect(res.body).to.have.property('status', 0); 289 | done(); 290 | }); 291 | }); 292 | 293 | it('should get qrcode status 0', function (done) { 294 | request 295 | .get(R.login) 296 | .end(function (err, res) { 297 | expect(err).to.be.null; 298 | expect(res.text).to.match(/password/); 299 | const csrf = getCSRF(res); 300 | request 301 | .post(R.qrcode) 302 | .type('form') 303 | .set('Accept', 'application/json') 304 | .send({ 305 | _csrf: csrf 306 | }) 307 | .end(function (err, res) { 308 | expect(err).to.be.null; 309 | expect(res).to.have.status(200); 310 | expect(res.body).to.have.property('status', 0); 311 | done(); 312 | }); 313 | }); 314 | }); 315 | 316 | it('should qrcode login success', function (done) { 317 | request 318 | .get(R.login) 319 | .end(function (err, res) { 320 | expect(err).to.be.null; 321 | expect(res).to.have.status(200); 322 | expect(res.text).to.match(/test@example\.com/); 323 | done(); 324 | }); 325 | }); 326 | }); 327 | -------------------------------------------------------------------------------- /test/util.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { expect } from 'chai'; 4 | import * as util from '../src/util'; 5 | 6 | describe('auth-center util', function () { 7 | this.timeout(0); 8 | 9 | it('should encrypt', function () { 10 | expect(util.encrypt('', 'salt')).to.be.a('null'); 11 | expect(util.encrypt('pass', 'salt')).to.be.equal('a8592083477a8168ca67bb4fdb36a61be698536a'); 12 | }); 13 | 14 | it('should checkURI', function () { 15 | /** 16 | * CALLBACK: http://example.com/path 17 | * GOOD: http://example.com/path 18 | * GOOD: http://example.com/path/subdir/other 19 | * BAD: http://example.com/bar 20 | * BAD: http://example.com/ 21 | * BAD: http://example.com:8080/path 22 | * BAD: http://oauth.example.com:8080/path 23 | * BAD: http://example.org 24 | */ 25 | 26 | const base = 'http://example.com/path'; 27 | 28 | expect(util.checkURI('//example.com', 'http://example.com/')).to.be.true; 29 | expect(util.checkURI('https://example.com/', '//example.com')).to.be.true; 30 | expect(util.checkURI('http://localhost', 'http://localhost/')).to.be.true; 31 | expect(util.checkURI('http://localhost/', 'http://localhost')).to.be.true; 32 | expect(util.checkURI('file://', 'file:///index.html')).to.be.true; 33 | expect(util.checkURI('file:///', 'file:///index.html')).to.be.true; 34 | 35 | expect(util.checkURI(base, 'http://example.com/path')).to.be.true; 36 | expect(util.checkURI(base, 'http://example.com/path/subdir/other')).to.be.true; 37 | 38 | expect(util.checkURI(base, 'http://example.com/bar')).to.be.false; 39 | expect(util.checkURI(base, 'http://example.com/')).to.be.false; 40 | expect(util.checkURI(base, 'http://example.com:8080/path')).to.be.false; 41 | expect(util.checkURI(base, 'http://oauth.example.com:8080/path')).to.be.false; 42 | expect(util.checkURI(base, 'http://example.org')).to.be.false; 43 | }); 44 | 45 | it('should generateId', function () { 46 | expect(util.generateId()).to.have.lengthOf(40); 47 | expect(util.generateId()).to.match(/^[A-Za-z0-9_-]+$/); 48 | }); 49 | 50 | it('should buildURI', function () { 51 | expect(util.buildURI('http://example.com/path?param=123¶m2=234#hello', { 52 | test: '123' 53 | })).to.be.equal('http://example.com/path?param=123¶m2=234&test=123#hello'); 54 | }); 55 | 56 | it('should totpURI', function () { 57 | expect(util.totpURI('test', 'test_key')).to.be.equal('otpauth://totp/test?secret=ORSXG5C7NNSXS'); 58 | }); 59 | 60 | it('should totpImage', function () { 61 | const buf = util.totpImage('test', 'test_key'); 62 | 63 | expect(buf[0]).to.be.equal(0x89); 64 | expect(buf[1]).to.be.equal(0x50); 65 | expect(buf[2]).to.be.equal(0x4E); 66 | expect(buf[3]).to.be.equal(0x47); 67 | }); 68 | 69 | it('should pagination', function () { 70 | const link = i => i; 71 | expect(util.pagination(1, 1, link)).to.be.equal(''); 72 | expect(util.pagination(1, 3, link)).to.be.equal(''); 73 | expect(util.pagination(1, 6, link)).to.be.equal(''); 74 | expect(util.pagination(5, 6, link)).to.be.equal(''); 75 | expect(util.pagination(6, 6, link)).to.be.equal(''); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | export function decode (input) { 2 | return input.replace(/[\t\x20]$/gm, '') 3 | .replace(/=(?:\r\n?|\n|$)/g, '') 4 | .replace(/=([a-fA-F0-9]{2})/g, (m, p1) => { 5 | return String.fromCharCode(parseInt(p1, 16)); 6 | }); 7 | } 8 | 9 | export function getCSRF (res) { 10 | const { headers } = res; 11 | const cookies = headers['set-cookie'] || []; 12 | for (const cookie of cookies) { 13 | const m = cookie.match(/XSRF-TOKEN=(.*);/i); 14 | if (m && m[1]) { 15 | return m[1]; 16 | } 17 | } 18 | return ''; 19 | } 20 | -------------------------------------------------------------------------------- /views/admin/clients.html: -------------------------------------------------------------------------------- 1 | {% extends './layout.html' %} 2 | {% block head %} 3 | Clients 4 | {% endblock %} 5 | 6 | {% block content %} 7 | {% if messages.success %} 8 |
9 | 10 | {{messages.success}} 11 |
12 | {% endif %} 13 | {% if messages.error %} 14 |
15 | 16 | {{messages.error}} 17 |
18 | {% endif %} 19 |
20 | 23 |
24 |
25 | 26 |
27 | 28 |
29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | {% if clients.rows.length == 0 %} 42 | 43 | 44 | 45 | {% else %} 46 | {% for d in clients.rows %} 47 | 48 | 52 | 53 | 54 | 55 | 62 | 63 | {% endfor %} 64 | {% endif %} 65 | 66 |
NameIDSecretRedirect URIOperation
No data available in table
49 | {{d.name}}
50 | {{d.name_cn}} 51 |
{{d.id}}{{d.secret}}{{d.redirect_uri}} 56 |
57 | 58 | 59 | 60 |
61 |
67 | 70 | 103 | {% endblock %} 104 | -------------------------------------------------------------------------------- /views/admin/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% if logo %} 9 | 10 | {% endif %} 11 | 12 | 13 | {% block head %}{% endblock %} 14 | 15 | 16 | 33 |
34 |
35 | 50 |
51 |
52 | {% block content %}{% endblock %} 53 |
54 |
55 |
56 |
57 | 58 | {% block foot %}{% endblock %} 59 | 60 | -------------------------------------------------------------------------------- /views/admin/roles.html: -------------------------------------------------------------------------------- 1 | {% extends './layout.html' %} 2 | {% block head %} 3 | Roles 4 | {% endblock %} 5 | 6 | {% block content %} 7 | {% if messages.success %} 8 |
9 | 10 | {{messages.success}} 11 |
12 | {% endif %} 13 | {% if messages.error %} 14 |
15 | 16 | {{messages.error}} 17 |
18 | {% endif %} 19 |
20 | 23 |
24 |
25 | 26 |
27 |
28 | 34 |
35 | 36 |
37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | {% if roles.rows.length == 0 %} 49 | 50 | 51 | 52 | {% else %} 53 | {% for d in roles.rows %} 54 | 55 | 56 | 57 | 58 | 65 | 66 | {% endfor %} 67 | {% endif %} 68 | 69 |
UserClientRoleOperation
No data available in table
{{userMap[d.user_id]}}{{clientMap[d.client_id]}}{{d.role}} 59 |
60 | 61 | 62 | 63 |
64 |
70 | 73 | 117 | {% endblock %} 118 | {% block foot %} 119 | 120 | {% endblock %} 121 | -------------------------------------------------------------------------------- /views/admin/users.html: -------------------------------------------------------------------------------- 1 | {% extends './layout.html' %} 2 | {% block head %} 3 | Users 4 | {% endblock %} 5 | 6 | {% block content %} 7 | {% if messages.success %} 8 |
9 | 10 | {{messages.success}} 11 |
12 | {% endif %} 13 | {% if messages.error %} 14 |
15 | 16 | {{messages.error}} 17 |
18 | {% endif %} 19 |
20 |
21 |
22 | 23 |
24 | 25 |
26 |
27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {% if users.rows.length == 0 %} 38 | 39 | 40 | 41 | {% else %} 42 | {% for d in users.rows %} 43 | 44 | 45 | 52 | 55 | 62 | 63 | {% endfor %} 64 | {% endif %} 65 | 66 |
EmailKeyUpdated AtOperation
No data available in table
{{d.email}} 46 | {% if d.totp_key %} 47 | Existed 48 | {% else %} 49 | None 50 | {% endif %} 51 | 53 | {{d.updatedAt.toLocaleString('en-US', { hour12: false })}} 54 | 56 |
57 | 58 | 59 | 60 |
61 |
67 | 70 | {% endblock %} 71 | -------------------------------------------------------------------------------- /views/auth/change.html: -------------------------------------------------------------------------------- 1 | {% extends './layout.html' %} 2 | 3 | {% block title %} 4 | {{__(title)}} 5 | {% endblock %} 6 | 7 | {% block content %} 8 |

{{__(title)}}

9 |
10 |
11 | 12 | 13 | 14 |
15 | 16 |
17 |
18 | 19 |
20 |
21 | 22 |
23 | {% if messages.error %} 24 |
{{__(messages.error)}}
25 | {% endif %} 26 |
27 |
28 | {% endblock %} -------------------------------------------------------------------------------- /views/auth/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% block title %}{% endblock %} 12 | {% if favicon %} 13 | 14 | {% endif %} 15 | 16 | 17 | 18 | 19 | 24 |
25 | {% block content %}{% endblock %} 26 |
27 |
28 | 中文 29 | 30 | English 31 |
32 | 33 | {% block foot %}{% endblock %} 34 | 35 | -------------------------------------------------------------------------------- /views/auth/login.html: -------------------------------------------------------------------------------- 1 | {% extends './layout.html' %} 2 | 3 | {% block title %} 4 | {{__('Sign in to system')}} 5 | {% endblock %} 6 | 7 | {% block content %} 8 |

{{__('Sign in to system')}}

9 |
10 | 20 |
21 | 22 |
23 | 24 |
25 |
26 | 27 |
28 | {% if isTOTP %} 29 |
30 |
31 | 32 | 33 | 34 | 35 |
36 |
37 | {% endif %} 38 | {% if terms %} 39 |

40 | 41 | 42 | {{__('the terms of use.')}} 43 |

44 | {% endif %} 45 |
46 | 47 |
48 | {% if messages.error %} 49 |
{{__(messages.error)}}
50 | {% elseif messages.success %} 51 |
{{__(messages.success)}}
52 | {% else %} 53 | 54 | {% endif %} 55 |
56 | 65 |
66 | 69 | {% endblock %} 70 | 71 | {% block foot %} 72 | 73 | {% endblock %} -------------------------------------------------------------------------------- /views/auth/reset.html: -------------------------------------------------------------------------------- 1 | {% extends './layout.html' %} 2 | 3 | {% block title %} 4 | {{__("Retrieve your password")}} 5 | {% endblock %} 6 | 7 | {% block content %} 8 |

{{__("Retrieve your password")}}

9 |
10 |
11 |
12 | {{__("Enter the account you want to retrieve the password")}} 13 |
14 |
15 | 16 |
17 |
18 |
19 | 20 | 21 | 22 | 23 |
24 |
25 |
26 | 27 |
28 | {% if messages.error %} 29 |
{{__(messages.error)}}
30 | {% elseif messages.success %} 31 |
{{__(messages.success)}}
32 | {% else %} 33 | 34 | {% endif %} 35 |
36 |
37 | {% endblock %} 38 | {% block foot %} 39 | 40 | {% endblock %} -------------------------------------------------------------------------------- /views/error.html: -------------------------------------------------------------------------------- 1 | {% extends './auth/layout.html' %} 2 | 3 | {% block title %} 4 | {{__(message)}} 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 |

{{__(status)}}

10 |

{{__(message)}}

11 |
12 | {% endblock %} -------------------------------------------------------------------------------- /views/main/home.html: -------------------------------------------------------------------------------- 1 | {% extends './layout.html' %} 2 | 3 | {% block title %} 4 | {{__("Home")}} 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | {% for c in clients -%} 10 | 19 | {%- endfor %} 20 |
21 | {% endblock %} -------------------------------------------------------------------------------- /views/main/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% block title %}{% endblock %} 12 | {% if favicon %} 13 | 14 | {% endif %} 15 | 16 | 17 | 18 | 19 | 44 |
45 |
46 | {% block content %}{% endblock %} 47 |
48 |
49 | 50 | {% block foot %}{% endblock %} 51 | 52 | -------------------------------------------------------------------------------- /views/main/security.html: -------------------------------------------------------------------------------- 1 | {% extends './layout.html' %} 2 | 3 | {% block title %} 4 | {{__("Security")}} 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 |
10 |
11 | 12 |
13 | {{__("Change Password")}} 14 |
15 |
16 |
17 | 18 |
19 |
20 | 21 |
22 |
23 | 24 |
25 |
26 | 27 |
28 | {% if messages.error %} 29 |
{{__(messages.error)}}
30 | {% endif %} 31 |
32 |
33 |
34 |
35 | {% endblock %} --------------------------------------------------------------------------------