Tuesday 18 December 2018

Bank express + ejs + mongodb, authentication jwt + bcrypt

project urlhttps://chuanshuoge1-bank.herokuapp.com/
welcome page

signup page register bob (password abc) sam (password 123)

check customers, bob, sam are registered, password in database is hashed

log in to bob's account

obtain a token from server after authentication, valid token is required for all transactions. 
token is valid for 2min, sign in again to obtain another after expiration

within token valid time, deposit $100

with valid token, withdraw $5

wire transfer $50 to sam

login to sam's account, sam receives $50 from bob

check accounts on mongodb cloud

accout schema

package.json

{
  "name": "express-mango-signin",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "dev": "nodemon index.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "axios": "^0.18.0",
    "bcryptjs": "^2.4.3",
    "ejs": "^2.6.1",
    "express": "^4.16.4",
    "express-jwt": "^5.3.1",
    "jsonwebtoken": "^8.4.0",
    "mongoose": "^5.3.15",
    "mongoose-timestamp": "^0.6.0"
  },
  "devDependencies": {
    "nodemon": "^1.18.8"
  }
}

--------------------------------------------------------------

index.js

const express = require('express');
const mongoose = require('mongoose');
const config = require('./config');
const bodyParser = require('body-parser');
const account = require('./models/account_model');

const app = express();

app.set('view engine', 'ejs');

//Body parser Middleware
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));

//welcome page
app.get('/', (req, res) => res.render('pages/index'));

//get all users' login
app.get('/users', async (req, res, next)=>{
    try{
        const users = await account.find({});
        let user_login =[];
        users.forEach(user => {
            user_login.push({name: user.name, password: user.password});
        });
        res.render('pages/users', {users: user_login})
    }catch(err){
        return next(err);
    }
})

//signup page
app.get('/signup', (req, res) => res.render('pages/signup'));

//login page
app.get('/auth', (req, res) => res.render('pages/login'));

//delete a user
/%
app.delete('/users/:id', async(req, res, next)=>{

    try{
        const new_user = await 
        account.findOneAndRemove({_id: req.params.id});
        res.sendStatus(204)
    }catch(err){
        return next(new Error('user not found'));
    }
});%/

app.listen(config.PORT, ()=>{
    mongoose.connect(config.MONGODB_URI, {useNewUrlParser: true});
});

const db = mongoose.connection;

db.on('error', (err)=>console.log(err));

db.once('open', ()=>{
    require('./routes/signup')(app);
    require('./routes/login')(app);
    require('./routes/userAccount')(app);
    console.log('server started on port ',config.PORT);
});

------------------------------------------------------

models/account_model.js

const mongoose = require('mongoose');
const timestamp= require('mongoose-timestamp');

const account_schema = new mongoose.Schema({
    name:{
        type: String,
        required: true,
        trim: true
    },
    balance:{
        type: Number,
        trim: true
    },
    password:{
        type: String,
        required: true
    },
    ledger:{
        type: [String],
    }
})

account_schema.plugin(timestamp);

const account = mongoose.model('account', account_schema);

module.exports = account;

----------------------------------------------------------

routes/signup.js

const bcrypt = require('bcryptjs');
const account = require('../models/account_model');

module.exports = app => {

    //add a user
    app.post('/signup', async(req, res, next)=>{
    
        const {name, password} = req.body;

        //check if user exist
        const existing_user = await account.findOne({name: name});
        //user exist return
        if(existing_user){
            res.render('pages/signup',  {message: 'user already exists'});
            return;
        }
    
        const user = new account({
            name,
            password,
            balance: 0,
            ledger: [],
        });
    
        bcrypt.genSalt(5, (err, salt) => {
            bcrypt.hash(user.password, salt, async(err, hash) => {
                //hash password
                user.password = hash;
                //save user
                try{
                    const new_user = await user.save();
                    res.render('pages/signup',  {message: 'user created'});
                }catch(err){
                    //return next(err);
                    res.render('pages/signup',  {message: err.toString()});
                }
            });
        });
        
    });
}

-----------------------------------------------------

routes/login.js

const bcrypt = require('bcryptjs');
const mongoose = require('mongoose');
const account = mongoose.model('account');
const jwt = require('jsonwebtoken');
const config = require('../config');

const authenticate = (name, password) => {
    return new Promise(async (resolve, reject)=>{
        try{
            //get user by name
            const user = await account.findOne({name: name});

            //check password
            bcrypt.compare(password, user.password, (err,isMatch)=>{
                if(err){throw err;}
                if(isMatch){
                    resolve(user);
                }else{
                    //password didn't match
                    reject('user/password not match');
                }
            });
        }catch(err){
            //user name not found
            reject('user not exist');
        }
    })
}

module.exports = app => {
    app.post('/auth',async (req,res,next)=>{
        const {name, password} = req.body;

        try{
            //authenticate user
            const user = await authenticate(name, password);
            
            //create jwt
            const token = jwt.sign(user.toJSON(), config.JWT_SECRET, {
                expiresIn: '2m'
            });

            const {iat, exp} = jwt.decode(token);

            //token duration
            const expire = exp - iat;

            //respond with token
            //res.send({iat, exp, token});
            res.render('pages/login', 
            {
             message: 'login successful',
             user: {name: user.name, id: user._id},   //only pass id, name to front end
             token,
             expire,
             projectURL: config.URL,
        });

        }catch(err){
            //return next(err);
            res.render('pages/login',  {message: err.toString()});
        }
    });
}

------------------------------------------------------

routes/userAccount.js


const mongoose = require('mongoose');
const account = mongoose.model('account');
const config = require('../config');
const rjwt = require('express-jwt');

module.exports = app => {
    //protected route, retrieve user account with token and id 
    app.post('/balance/:id', rjwt({secret: config.JWT_SECRET}), async (req,res,next)=>{

        try{
            //get user by id
            const user = await account.findById(req.params.id);
            
            res.send(user);

        }catch(err){
            return next(err);
        }
    });

    //protected route, update user balance with token and id
    app.put('/deposite/:id', rjwt({secret: config.JWT_SECRET}), async (req,res,next)=>{
        
        const { amount} = req.body;
        const {id} = req.params;

        try{
            //get user by id
            const user_old = await account.findById(id);

            //calculate balance after transaction
            const new_balance = user_old.balance + parseFloat(amount); 

            //record transaction
            const d = new Date();
            const transaction = d.toISOString() + ' deposite $' + amount;

            //only save last 10 transactions in ledger
            const new_ledger = [...user_old.ledger].concat(transaction).splice(-10,10);

            //update balance and ledger
            await account.findOneAndUpdate({_id: id}, {"balance": new_balance.toFixed(2), "ledger": new_ledger});
            
            //get user by id
            const user_new = await account.findById(id);
            
            res.send(user_new);

        }catch(err){
            return next(err);
        }
    });

    //protected route, update user balance with token and id
    app.put('/withdraw/:id', rjwt({secret: config.JWT_SECRET}), async (req,res,next)=>{
        
        const { amount} = req.body;
        const {id} = req.params;

        try{
            //get user by id
            const user_old = await account.findById(id);

            //calculate balance after transaction
            const new_balance = user_old.balance - parseFloat(amount);
         
            //new balance < 0 throw error, protect over withdraw on server side
            if(new_balance < 0){throw 'not enough $ in account';}

            //record transaction
            const d = new Date();
            const transaction = d.toISOString() + ' withdraw $' + amount;

            //only save last 10 transactions in ledger
            const new_ledger = [...user_old.ledger].concat(transaction).splice(-10,10);

            //update balance and ledger
            await account.findOneAndUpdate({_id: id}, {"balance": new_balance.toFixed(2), "ledger": new_ledger});
            
            //get user by id
            const user_new = await account.findById(id);

            res.send(user_new);

        }catch(err){
            //return next(err);
            res.send({error: err.toString()});
        }
    });

    //protected route, update user balance with token and id
    app.put('/wireTransfer/:id', rjwt({secret: config.JWT_SECRET}), async (req,res,next)=>{
        
        const { amount, recipient} = req.body;
        const {id} = req.params;

        try{
            //if recipient not found, throw error
            const recipientAccount = await account.findOne({name: recipient});
            if(recipientAccount===null){throw 'wire transfer recipient not found';}

            //get user by id
            const user_old = await account.findById(id);

            // balances 
            const new_balance = user_old.balance - parseFloat(amount);
            const new_balance_recipient = recipientAccount.balance + parseFloat(amount);
            
            //new balance < 0 throw error, protect over withdraw on server side
            if(new_balance < 0){throw 'not enough $ in account';}

            //record transactions
            const d = new Date();
            const transactionSend = d.toISOString() + ' send $' + amount + ' to ' + recipient;
            const transactionReceive = d.toISOString() + ' receive $' + amount + ' from ' + user_old.name;

            //only save last 10 transactions in ledger
            const new_ledgerSender = [...user_old.ledger].concat(transactionSend).splice(-10,10);
            const new_ledgerReceiver = [...recipientAccount.ledger].concat(transactionReceive).splice(-10,10);

            //update accounts
            await account.findOneAndUpdate({_id: id}, {"balance": new_balance.toFixed(2), "ledger": new_ledgerSender});  
            await account.findOneAndUpdate({_id: recipientAccount._id}, {"balance": new_balance_recipient.toFixed(2), "ledger": new_ledgerReceiver});
            
            //get user by id
            const user_new = await account.findById(id);
            
            res.send(user_new);

        }catch(err){
            //return next(err);
            res.send({error: err.toString()});
        }
    });

    //display user account
    app.post('/userAccount/:id', (req,res,next)=>{
        const {userId, name, balance, created, updated, message, ledger} = req.body;
        
        res.render('pages/userAccount', {
            id: userId,
            name,
            balance,
            created,
            updated,
            message,
            ledger: ledger.split(','),
        });
    });
}

-----------------------------------------------------------------------------------

partials/localstorage.ejs

<!-- after authentication, store user, token in localstorage -->
<% if (typeof user != 'undefined'){ %>

    <div id='user' style="display: none;"><%= user.name %></div> 
    <div id='id' style="display: none;"><%= user.id %></div>       
  <% } %>

<% if (typeof token != 'undefined'){ %>

    <div id='token' style="display: none;"><%= token %></div>       
  <% } %>

<% if (typeof expire != 'undefined'){ %>

    <div id='expire' style="display: none;"><%= expire %></div>       
  <% } %>

<% if (typeof projectURL != 'undefined'){ %>

    <div id='projectURL' style="display: none;"><%= projectURL %></div>       
  <% } %>
  
  <script>
    var user = document.getElementById("user").innerHTML.trim();
    var id = document.getElementById("id").innerHTML.trim();
    var token = document.getElementById("token").innerHTML.trim();
    var expire = document.getElementById("expire").innerHTML.trim();
    var projectURL = document.getElementById("projectURL").innerHTML.trim();
  
    if(user !== null){
        localStorage.setItem("user", user);
    }

    if(id !== null){
        localStorage.setItem("id", id);
    }

    if(token !== null){
        localStorage.setItem("token", token);
        var d = new Date();
        localStorage.setItem("issued", d.getTime());
    }

    if(expire !== null){
        localStorage.setItem("expire", expire);
    }

    if(projectURL !== null){
        localStorage.setItem("projectURL", projectURL);
    }
    
  </script>

---------------------------------------------------------------------

partials/redirecToAccount.ejs

 <!-- hidden form used for redirect to user account once authenticated -->
 <form style="display: none;" class="container" method="POST" id='redirectForm'>
    <input type="hidden" id='userId' name='userId'>
    <input type="hidden" id='userName' name='name'>
    <input type="hidden" id='userBalance' name='balance'>
    <input type="hidden" id='userCreated' name='created'>
    <input type="hidden" id='userUpdated' name='updated'>
    <input type="hidden" id='message' name='message'>
    <input type="hidden" id='ledger' name='ledger'>
    <button id='redirectButton' type="submit"></button>
  </form>

  <script>
      //retrieve user account with token and id from database, then redirect to account page
    function rediectToAccount(method, route, amount, recipient){

       var projectURL = localStorage.getItem("projectURL");
       var id = localStorage.getItem("id");
       var token = localStorage.getItem("token");

       var postData = {"amount": amount, "recipient": recipient};

       var jwt = 'Bearer ' + token;
       var headers = {
           'Content-Type': 'application/json',
           'Authorization':  jwt,
           "Access-Control-Allow-Origin": "*",
        }

       var actionURL = projectURL + route + '/' + id;

       axios({
           method: method,
           url: actionURL,
           data: postData,
           headers: headers,
        })
       .then((res)=>{
           const {_id, name, balance, createdAt, updatedAt, ledger, error} = res.data;

           //if server didn't reply user info, throw error meessage
           if(typeof _id === 'undefined'){throw error;}

           var userId = document.getElementById('userId');
           var userName = document.getElementById('userName');
           var userBalance = document.getElementById('userBalance');
           var userCreated = document.getElementById('userCreated');
           var userUpdated = document.getElementById('userUpdated');
           var successMessage = document.getElementById('message');
           var userLedger = document.getElementById('ledger');

           userId.value = _id;
           userName.value = name;
           userCreated.value = createdAt;
           userUpdated.value = updatedAt;
           successMessage.value = route.slice(1) + ' processed';
           userLedger.value = ledger;

           if(typeof balance === 'undefined'){
               userBalance.value = 0;
           }else{
               userBalance.value = balance;
           }

           //set redirect form submit route
           document.getElementById('redirectForm').action = '/userAccount/'+_id;

           //submit form, redirect to account page
           document.getElementById('redirectButton').click();
       })
       .catch((err) => {
           alert(err);
           //if token is invalid or over withdraw, logout, redirect to welcome page
           logout();
           window.location.href = projectURL;
       })
    }

    //remove user, token from local storage when logged out
    function logout(){
      localStorage.removeItem("user");
      localStorage.removeItem("token");
      localStorage.removeItem("expire");
    }
  </script>

<script src="https://unpkg.com/axios/dist/axios.min.js"></script>

-------------------------------------------------------------------

partials/headers.ejs

<!-- retrieve user account with token from database, then redirect to account page -->
<% include ./redirectToAccount%>

<nav class="navbar sticky-top navbar-expand-sm navbar-light" style="background-color: #e3f2fd;">
        <a class="navbar-brand" href="/">Bank</a>
        <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
          <span class="navbar-toggler-icon"></span>
        </button>
      
        <div class="collapse navbar-collapse" id="navbarNav">
          <ul class="navbar-nav mr-auto">           

            <li class="nav-item">
              <a class="nav-link" href="/users">Customers</a>
            </li> 

          </ul>
          
          <ul id='menuRight' class="navbar-nav my-2 my-lg-0">

          </ul>
        </div>
      </nav>

  <script> 
    var user = localStorage.getItem("user");
    
    if(user !== null){
      //redirectToAccount() included in redirectToAccount.ejs
      document.getElementById('menuRight').innerHTML = 
      "<li><a class='nav-link' onclick='toAccount()'>" + user + "</a></li>" + 
      "<li><a class='nav-link' href='/' onclick='logout()'>Logout</a></li>";
    }else{
      document.getElementById('menuRight').innerHTML = 
      "<li><a class='nav-link' href='/signup'>Signup</a></li>" + 
      "<li><a class='nav-link' href='/auth'>Login</a></li>";
    }

    function toAccount(){
      rediectToAccount('post', '/balance');
    }
  </script>

    <!-- Optional JavaScript -->
    <!-- jQuery first, then Popper.js, then Bootstrap JS -->
    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>

--------------------------------------------------------

views/userAccount.ejs

<!-- views/pages/index.ejs -->

<!DOCTYPE html>
<html lang="en">
<head>
    <% include ../partials/head %>
</head>
<body>

<header>

    <% include ../partials/header %>
</header>

<main>
    <div class="jumbotron">
        <button class="btn btn-warning btn-sm" data-toggle="collapse" data-target="#token">Token</button>
        Valid for <span class="text-danger" id='timer'></span>S 
        <button class="btn btn-danger btn-sm" onclick="revoke()">Revoke</button><br><br>
        <span id="token" class="collapse"></span>

        <span>Customer ID: <%= id %></span><br>
        <span>Customer Since: <%= created %></span><br>
        <span>Last Transaction: <%= updated %></span><br><br>       
        <span>Balance: $<span id='balance'><%= balance %></span></span><br> 

        <% if (typeof message != 'undefined'){ %>
            <span class="text-info">Status: <%= message %></span><br>      
          <% } %>

        <table class="table">
            <thead>
                <tr>
                    <th scope="col">Action</th>
                    <th scope="col">Parameter</th>
                </tr>
                <tbody>
                    <tr>
                        <td> <button class="btn btn-success btn-sm" onclick="deposite()">Deposite</button></td>
                        <td>$<input type="number" id="depositeAmount" onchange="abs(this)" value=0></td>
                    </tr>
                    <tr>
                        <td><button class="btn btn-success btn-sm" onclick="withdraw()">Withdraw</button> </td>
                        <td>$<input type="number" id="withdrawAmount" onchange="abs(this)" value=0></td>
                    </tr>
                    <tr>
                            <td><button class="btn btn-success btn-sm" onclick="wireTransfer()">Wire Transfer</button> </td>
                            <td>
                                <div class="row">
                                    <div class="col">
                                         $<input type="number" id="transferAmount" onchange="abs(this)" value=0 >
                                    </div>
                                    <div class="col">
                                        To <input type="text" id="recipient" placeholder="recipient name">
                                    </div>
                                </div>
                            </td>
                        </tr>
                </tbody>
            </thead>
        </table>

        <h4>Transaction History</h4>

        <% ledger.forEach(function(transaction) { %>
            <span><%= transaction %></span><br>
          <% }); %>

    </div>
    <script>
        document.getElementById('token').innerHTML = localStorage.getItem('token');

        //token expire timer
        var duration = localStorage.getItem('expire');
        var issued = localStorage.getItem('issued');
        var timer = document.getElementById('timer');

        var counter = setInterval(()=>{
            //second elapsed since token issued
            var d = new Date();
            var elapsed = parseInt((d.getTime() - parseInt(issued))/1000);
            var countdown = parseInt(duration) - elapsed;

            timer.innerHTML = countdown;

            //stop timer when token expire
            if(countdown <= 0){
                clearInterval(counter);
             
            }
        },1000)

        //revoke token when button clicked
        function revoke(){
            localStorage.removeItem("token");
            clearInterval(counter);
            timer.innerHTML = 0;
            document.getElementById('token').innerHTML = 'revoked';
        }

        //get absolute 2 decimal precision of amount
        function abs(obj){
            obj.value = Math.abs(obj.value).toFixed(2);
        }

        //redirect to deposite route in userAccount.js
        function deposite(){
            var amount = document.getElementById('depositeAmount').value;
            rediectToAccount('put', '/deposite', amount);
        }

        //redirect to withdraw route in userAccount.js
        function withdraw(){
            var amount = document.getElementById('withdrawAmount').value;
            //check if account has enough $
            if(!checkWithdraw(amount)){return}

            rediectToAccount('put', '/withdraw', amount);
        }

        //check if $ is enough in account for withdraw
        function checkWithdraw(withdrawAmount){
            var balance = document.getElementById('balance').innerHTML;

            //protect over withdraw from client side
            if(withdrawAmount > parseFloat(balance)){
                alert('short of $ in account');
                return false;
            }
            return true;
        }

        //redirect to wire transfer route in userAccount.js
        function wireTransfer(){
            var recipient = document.getElementById('recipient').value;
            var amount = document.getElementById('transferAmount').value;

            //if recipient is not entered, return
            if(recipient===''){alert('recipient empty'); return}

            //check if account has enough $
            if(!checkWithdraw(amount)){return}

            rediectToAccount('put', '/wireTransfer', amount, recipient);
        }
    </script>
    
</main>

<footer>
    <% include ../partials/footer %>
</footer>

</body>
</html>

---------------------------------------------------------------------------

pages/login.ejs

<!-- views/pages/index.ejs -->

<!DOCTYPE html>
<html lang="en">
<head>
    <% include ../partials/head %>
</head>
<body>

<header>
    <!-- authentication passed, store user, token in local storage -->
    <% include ../partials/localStorage %>

    <% include ../partials/header %>
   
</header>

<main>  
    <form class="container" method="POST" action="/auth">
        <h4>Login</h4>  
        <div class="form-group">
          <label for="exampleInputName">User Name</label>
          <input type="text" class="form-control" name="name" id="exampleInputName" placeholder="User Name" oninput="inputChange()">
        </div>
        <div class="form-group">
          <label for="exampleInputPassword1">Password</label>
          <input type="password" class="form-control" name="password" id="exampleInputPassword1" placeholder="Password" oninput="inputChange()">
        </div>
        
        <button type="submit" id='submit_button' disabled=true class="btn btn-primary">Login</button>
        
        <% if (typeof message != 'undefined'){ %>
            <p class="text-info"><%= message %>
            <br><span id='timer'></span></p>
        <% } %>
      </form>

</main>

<footer>
    <% include ../partials/footer %>
</footer>

<script>
   
    function inputChange() {
        var name = document.getElementById('exampleInputName').value;
        var password = document.getElementById('exampleInputPassword1').value;
        var submit_button = document.getElementById('submit_button');

        if(name !== '' && password !== ''){
            submit_button.disabled = false;
        }else{
            submit_button.disabled = true;
        }
    }

    //redirect to user account if authentication is successful
    var user = localStorage.getItem("user");

    if(user !== null){
        //redirect timer
        var seconds = 0;
        var timer = document.getElementById('timer');
        timer.innerHTML = 'redirecting to my account ';

        var counter = setInterval(()=>{
            timer.innerHTML += '$';
            seconds++;

            if(seconds === 10){
                clearInterval(counter);
                
                //wait for 1 seconds before redirect to account
                //function included in redirectToAccount.ejs in header.ejs
                rediectToAccount('post', '/balance');
                
            }
        },100)
    }

</script>
</body>
</html>

-------------------------------------------------------------

config.js

module.exports={
    ENV:process.env.NODE_ENV || 'development',
    PORT:process.env.PORT || 3000,
    URL: process.env.BASE_URL || 'http://localhost:3000',
    MONGODB_URI: process.env.MONGODB_URI || 
    'mongodb://xxxxxx@ds131954.mlab.com:31954/bank',
    JWT_SECRET: process.env.JWT_SECRET || 'abc'
}

---------------------------------------------

pages/user.ejs

<!-- views/pages/index.ejs -->

<!DOCTYPE html>
<html lang="en">
<head>
    <% include ../partials/head %>
</head>
<body>

<header>
    <% include ../partials/header %>
</header>

<main>       
  <table class="table">
    <thead class="thead-light">
      <tr>
        <th scope="col">Name</th>
        <th scope="col">Hashed Password</th>
      </tr>
    </thead>
    <tbody>
     <% users.forEach(function(user) { %>
         <tr>
           <td> <%= user.name %> </td>
           <td> <%= user.password %></td> 
         </tr>
       <% }); %>
          
    </tbody>
  </table>
</main>

<footer>
    <% include ../partials/footer %>
</footer>

</body>
</html>

-----------------------------------------------------

partials/head.ejs

<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">

<title>Authentication</title>

--------------------------------------------

partials/footer.ejs

<p class="text-center text-muted">© chuanshuoge 2018</p>

-------------------------------------------

.gitignore

node_modules

-----------------------------------------
reference:

No comments:

Post a Comment