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:
- loggedin == true
- 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!