Skip to content

Commit

Permalink
Added support for CRAM-MD5 authentication method. Bumped version to v…
Browse files Browse the repository at this point in the history
…1.3.0
  • Loading branch information
andris9 committed Apr 21, 2015
1 parent cf4b028 commit ff4b27f
Show file tree
Hide file tree
Showing 5 changed files with 120 additions and 12 deletions.
31 changes: 29 additions & 2 deletions README.md
Expand Up @@ -111,7 +111,7 @@ Where
* **user** can be any value - if this is set then the user is considered logged in and this value is used later with the session data to identify the user. If this value is empty, then the authentication is considered failed
* **data** is an object to return if XOAUTH2 authentication failed (do not set the error object in this case). This value is serialized to JSON and base64 encoded automatically, so you can just return the object

This module does not support `CRAM` based authentications since these require access to unencrypted user password during the authentication process. You shouldn't store passwords unencrypted anyway, so supporting it doesn't make much sense.
This module supports `CRAM-MD5` but the use of it is discouraged as it requires access to unencrypted user passwords during the authentication process. You shouldn't store passwords unencrypted.

### Examples

Expand All @@ -130,7 +130,7 @@ var server = new SMTPServer({

#### Oauth2 authentication

XOAUTH2 support needs to enabled with the `authMethods` array option to use it as it is disabled by default.
XOAUTH2 support needs to enabled with the `authMethods` array option as it is disabled by default.
If you support multiple authentication mechanisms, then you can check the used mechanism from the `method` property.

```javascript
Expand All @@ -155,6 +155,33 @@ var server = new SMTPServer({
});
```

#### CRAM-MD5 authentication

CRAM-MD5 support needs to enabled with the `authMethods` array option as it is disabled by default.
If you support multiple authentication mechanisms, then you can check the used mechanism from the `method` property.

This authentication method does not return a password with the username but a response to a challenge. To validate the returned challenge response, the authentication object includes a method `validatePassword` that takes the actual plaintext password as an argument and returns either `true` if the password matches with the challenge response or `false` if it does not.

```javascript
var server = new SMTPServer({
authMethods: ['CRAM-MD5'], // CRAM-MD5 is not enabled by default
onAuth: function(auth, session, callback){
if(auth.method !== 'CRAM-MD5'){
// should never occur in this case as only CRAM-MD5 is allowed
return callback(new Error('Expecting CRAM-MD5'));
}

// CRAM-MD5 does not provide a password but a challenge response
// that can be validated against the actual password of the user
if(auth.username !== 'abc' || !auth.validatePassword('def')){
return callback(new Error('Invalid username or password'));
}

callback(null, {user: 123}); // where 123 is the user id or similar property
}
});
```

## Validating sender addresses

By default all sender addresses (as long as these are in valid email format) are allowed. If you want to check
Expand Down
23 changes: 18 additions & 5 deletions examples/server.js
Expand Up @@ -23,16 +23,29 @@ var server = new SMTPServer({
// disable STARTTLS to allow authentication in clear text mode
disabledCommands: ['STARTTLS'],

// By default only PLAIN and LOGIN are enabled
authMethods: ['PLAIN', 'LOGIN', 'CRAM-MD5'],

// Setup authentication
// Allow only users with username 'testuser' and password 'testpass'
onAuth: function(auth, session, callback) {
if (auth.username !== 'testuser' && auth.password !== 'testpass') {
callback(new Error('Authentication failed'));
var username = 'testuser';
var password = 'testpass';

// check username and password
if (auth.username === username &&
(
auth.method === 'CRAM-MD5' ?
auth.validatePassword(password) : // if cram-md5, validate challenge response
auth.password === password // for other methods match plaintext passwords
)
) {
return callback(null, {
user: 'userdata' // value could be an user id, or an user object etc. This value can be accessed from session.user afterwards
});
}

return callback(null, {
user: 'userdata' // value could be an user id, or an user object etc. This value can be accessed from session.user afterwards
});
return callback(new Error('Authentication failed'));
},

// Validate MAIL FROM envelope address. Example allows all addresses that do not start with 'deny'
Expand Down
71 changes: 68 additions & 3 deletions lib/sasl.js
@@ -1,5 +1,8 @@
'use strict';

var util = require('util');
var crypto = require('crypto');

var SASL = module.exports = {

SASL_PLAIN: function(args, callback) {
Expand Down Expand Up @@ -47,6 +50,23 @@ var SASL = module.exports = {
SASL.XOAUTH2_token.call(this, false, args[0], callback);
},

'SASL_CRAM-MD5': function(args, callback) {
if (args.length) {
this.send(501, 'Error: syntax: AUTH CRAM-MD5');
return callback();
}

var challenge = util.format('<%s%s@%s>',
String(Math.random()).replace(/^[0\.]+/, '').substr(0, 8), // random numbers
Math.floor(Date.now() / 1000), // timestamp
this.name // hostname
);

this._nextHandler = SASL['CRAM-MD5_token'].bind(this, true, challenge);
this.send(334, new Buffer(challenge).toString('base64'));
return callback();
},

PLAIN_token: function(canAbort, token, callback) {
token = (token || '').toString().trim();

Expand All @@ -65,7 +85,7 @@ var SASL = module.exports = {
var username = data[1] || data[0] || '';
var password = data[2] || '';

this._server.logger.info('[%s] Trying to authenticate "%s"', this._id, username);
this._server.logger.info('[%s] Trying to authenticate "%s" with %s', this._id, username, 'PLAIN');
this._server.onAuth({
method: 'PLAIN',
username: username,
Expand All @@ -83,6 +103,7 @@ var SASL = module.exports = {
return callback();
}

this._server.logger.info('[%s] User "%s" authenticated');
this.session.user = response.user;

this.send(235, 'Authentication successful');
Expand Down Expand Up @@ -121,7 +142,7 @@ var SASL = module.exports = {

password = new Buffer(password, 'base64').toString();

this._server.logger.info('[%s] Trying to authenticate "%s"', this._id, username);
this._server.logger.info('[%s] Trying to authenticate "%s" with %s', this._id, username, 'LOGIN');
this._server.onAuth({
method: 'LOGIN',
username: username,
Expand All @@ -139,6 +160,7 @@ var SASL = module.exports = {
return callback();
}

this._server.logger.info('[%s] User "%s" authenticated');
this.session.user = response.user;

this.send(235, 'Authentication successful');
Expand Down Expand Up @@ -179,7 +201,7 @@ var SASL = module.exports = {
return callback();
}

this._server.logger.info('[%s] Trying to authenticate "%s"', this._id, username);
this._server.logger.info('[%s] Trying to authenticate "%s" with %s', this._id, username, 'XOAUTH2');
this._server.onAuth({
method: 'XOAUTH2',
username: username,
Expand All @@ -198,6 +220,7 @@ var SASL = module.exports = {
return callback();
}

this._server.logger.info('[%s] User "%s" authenticated');
this.session.user = response.user;

this.send(235, 'Authentication successful');
Expand All @@ -209,5 +232,47 @@ var SASL = module.exports = {
XOAUTH2_error: function(data, callback) {
this.send(535, 'Error: Username and Password not accepted');
return callback();
},

'CRAM-MD5_token': function(canAbort, challenge, token, callback) {
token = (token || '').toString().trim();

if (canAbort && token === '*') {
this.send(501, 'Authentication aborted');
return callback();
}

var tokenParts = new Buffer(token, 'base64').toString().split(' ');
var username = tokenParts.shift();
var challengeResponse = (tokenParts.shift() || '').toLowerCase();

this._server.logger.info('[%s] Trying to authenticate "%s" with %s', this._id, username, 'CRAM-MD5');
this._server.onAuth({
method: 'CRAM-MD5',
username: username,
validatePassword: function(password) {
var hmac = crypto.createHmac('md5', password);
return hmac.update(challenge).digest('hex').toLowerCase() === challengeResponse;
}
}, this.session, function(err, response) {

if (err) {
this._server.logger.info('[%s] Authentication error for "%s"\n%s', this._id, username, err.message);
this.send(err.responseCode || 535, err.message);
return callback();
}

if (!response.user) {
this.send(response.responseCode || 535, response.message || 'Error: Authentication credentials invalid');
return callback();
}

this._server.logger.info('[%s] User "%s" authenticated');
this.session.user = response.user;

this.send(235, 'Authentication successful');
callback();

}.bind(this));
}
};
4 changes: 2 additions & 2 deletions package.json
@@ -1,6 +1,6 @@
{
"name": "smtp-server",
"version": "1.2.0",
"version": "1.3.0",
"description": "Create custom SMTP servers on the fly",
"main": "lib/smtp-server.js",
"scripts": {
Expand All @@ -12,7 +12,7 @@
"devDependencies": {
"chai": "^2.2.0",
"grunt": "^0.4.5",
"grunt-contrib-jshint": "^0.11.1",
"grunt-contrib-jshint": "^0.11.2",
"grunt-mocha-test": "^0.12.7",
"mocha": "^2.2.4",
"sinon": "^1.14.1",
Expand Down
3 changes: 3 additions & 0 deletions test/smtp-connection-test.js
Expand Up @@ -748,6 +748,9 @@ describe('SMTPServer', function() {
});
});
});

// TODO: Add tests for CRAM-MD5
// smtp-connection does not support it currently
});

describe('Mail tests', function() {
Expand Down

0 comments on commit ff4b27f

Please sign in to comment.