Jingshao

LDAP authentication in passport with React

LDAP authentication in passport with React

When develop enterprise app, LDAP authentication is needed most of the time. I will show you how to do that in this blog.

What you need

You will need express as the server, Mongodb with mongoose as the session storage and local user database. passport with passport-ldapauth as the authentication middleware

On the front end, we will use react and Materail-UI to build a simple login page.

Server

The server is an restful api server that takes two request to authenticate a user or check if a user is already authenticated.

  • To authenticated the user, the user post the credentials to the server’s /api/login path.
    router.post('/login', passport.myLogin)
  • To check if a user is already authenticated, the user tries to get /api/user.
function ensureAuthenticated(req, res, next) {
  if (!req.user) {
    res.status(401).json({ success: false, message: "not logged in" })
  } else {
    next()
  }
}

router.get("/api/user", ensureAuthenticated, function (req, res) {
  res.json({success: true, user:req.user})
})

In both cases, when a user is authenticated, server will response with a json object {success: true, user: user}. If the user is not authenticated, server will send a 401 status code with a json object {success: false}

Then all we need to do is to implement myLogin in our passport module.

LDAP authentication

LDAP authentication could be tricky. Especially it is server dependent. So first, you will need to install a command line tool called ldapsearch to test different parameter from command line:

ldapsearch -H ldaps://directory.your-company.com  -x -b "cn=users,cn=accounts,dc=corp,dc=your-company,dc=com" -D "uid=your-username,cn=users,cn=accounts,dc=corp,dc=your-company,dc=com" -W -s sub 'uid=your-username'

It is important to get the dn correct. Once you can authenticate from the command line, it is easy to convert it into code:

var getLDAPConfiguration = function (req, callback) {
  process.nextTick(function () {
    var opts = {
      server: {
        url: ldapurl,
        bindDn: `uid=${req.body.username},${dn}`,
        bindCredentials: `${req.body.password}`,
        searchBase: dn,
        searchFilter: `uid=${req.body.username}`,
        reconnect: true
      }
    };
    callback(null, opts);
  });
};

passport.use(new LdapStrategy(getLDAPConfiguration,
  function (user, done) {
    winston.info("LDAP user ", user.displayName, "is logged in.")
    return done(null, user);
  }))

Passport use the serializeUser and deserializeUser to save user id into session and later retrieve details of the user:

passport.serializeUser(function (user, done) {
  done(null, user.uid)
})
passport.deserializeUser(function (id, done) {
  User.findOne({ uid: id }).exec()
    .then(user => {
      if (!user) {
        done(new Error(`Cannot find user with uid=${id}`))
      } else {
        done(null, user)
      }
    })
})

Save user to local database

In my implementation, after a user is first authenticated with LDAP server, I want to save this user to my local database when the user first time successfully logged in, Then I can retrieve the detail user information such as given name, email, etc without query LDAP server again. In order to do that, I need to customize passport’s login method, I call it myLogin:

passport.myLogin = function (req, res, next) {
  passport.authenticate('ldapauth', function (err, user, info) {
    if (err) {
      return next(err)
    }
    if (!user) {
      res.status(401).json({ success: false, message: 'authentication failed' })
    } else {
      req.login(user, loginErr => {
        if (loginErr) {
          return next(loginErr);
        }
        User.findOneAndUpdate({uid: user.uid}, user, {upsert: true, new: true}).exec().then(user=> {
          return res.json({ success: true, message: 'authentication succeeded', user: Object.assign({name: user.uid}, user) });
        })
      });
    }
  })(req, res, next)
}

In myLogin, I save the user’s details in passport’s req.login() function. I save all passport authentication related functions in a module called passport, and I invoke myLogin like this: router.post('/login', passport.myLogin)

User interface

I use React to construct the user login interface. I choose material-ui as the UI building blocks.

Normally, people use router to route between a login page and a page after logged in successfully. I will take a different approach. There is only one page, when user access this page, a get /api/user is sent to the server, and if the server response with {success: true, user: user}, we know the user has been authenticated, and we display the logged in view. If the server response with a {success: false}, we know this user is not logged in, and we display the login view.

React SPA

React is a library for view only. However, since the component in react is almost the same as a javascript function, with props as its arguments, we are very flexible on controlling how the view is displayed.

In our example, we want to check if the user is already logged in, and depends on the result, show a login view, or an application view.

Therefore, the page will have two states:

  1. loggedin == true
  2. loggedin == false

Accordingly, we do the following:

render() {
  if (this.state.loggedin) {
      return <MuiThemeProvider muiTheme={muiTheme} >
        <App user={this.state.user} />
      </MuiThemeProvider>
    }
    return <MuiThemeProvider muiTheme={muiTheme} >
      <Login onSubmit={this.login} />
    </MuiThemeProvider>
}

this.state.user is the details of the user the server send to us when we check if a user is logged in. and this.login is a function that post the login credentials the <Login/> view collected from the user’s inputs.

We wrote this.login to handle both login and checking login since they are very similar:

login = (credentials) => {
    if (credentials) {
      var path = `${apiPath}/login`
      var options = {
        // have to have this to allow cookie to be sent to server. Therefore authentication session can be reserved.
        credentials: 'same-origin',
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(credentials)
      }
    } else {
      path = `${apiPath}/api/user`
      options = { credentials: 'same-origin' }
    }
    fetch(path, options)
      .then(response => {
        if (response.status >= 200 && response.status < 300) {
          return response.json()
        } else {
          var error = new Error(response.statusText)
          error.response = response
          throw error
        }
      })
      .then(login => {
        this.setState({ loggedin: login.success, user: login.user})
      })
  }

When credential is defined, we know login is called from the Login page with username and password. Therefore, we post the credentials to /api/login to let the server authenticate. If login is called without credential, then we know it is checking if the user session has already been authenticated, therefore, we get /api/user.

For both scenario, the server is going to response with success or not, and if success, the detailed information of the logged in user.

And we call login at the beginning of the page:

constructor(props) {
  super(props)
  this.state = { loggedin: false }
  this.login()
}

A third state

When I run the page, after logged in, if I refresh the browser, I will see a flash of the login view, then it will be replaced with the application view. The reason is that it takes a little bit time for the page to send a request to the server and get the response back to verify if I have logged in or not. When the server response is not received, the page still think I am not logged in and show me the login view. And as soon as the server’s response is received, this.state.loggedin will become true and the application view will show.

To get rid of this flash, I added another state in the component: {fetching: true} So when this.state.fetching, the page will display a message says “Fetching data from server …". And when response from server is received, fetching will be set to false, and state.loggedin will decide if Login view or Application view should be displayed.

Revised code as follow:

class BasePage extends Component {
  constructor(props) {
    super(props)
    this.state = { loggedin: false, fetching: true }
    this.login()
  }

  login = (credentials) => {
    if (credentials) {
      var path = `${apiPath}/login`
      var options = {
        // have to have this to allow cookie to be sent to server. Therefore authentication session can be reserved.
        credentials: 'same-origin',
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(credentials)
      }
    } else {
      path = `${apiPath}/api/user`
      options = { credentials: 'same-origin' }
    }
    fetch(path, options)
      .then(response => {
        if (response.status >= 200 && response.status < 300) {
          return response.json()
        } else {
          var error = new Error(response.statusText)
          error.response = response
          throw error
        }
      })
      .then(login => {
        this.setState({ loggedin: login.success, user: login.user, fetching: false })
      })
      .catch((error) => {
        this.setState({ fetching: false })
      })
  }

  render() {
    if (this.state.fetching) {
      return <div>Fetching data from server ...</div>
    }
    if (this.state.loggedin) {
      return <MuiThemeProvider muiTheme={muiTheme} >
        <App user={this.state.user} />
      </MuiThemeProvider>
    }
    return <MuiThemeProvider muiTheme={muiTheme} >
      <Login onSubmit={this.login} />
    </MuiThemeProvider>
  }
}

ReactDOM.render(
  <BasePage />,
  document.getElementById('root')
);

Happy coding!


Share

comments powered by Disqus