Add support for users database on disk. (#262)

In order to simplify the deployment of Authelia for
testing, LDAP is now optional made optional thanks
to users database stored in a file. One can update
the file manually even while Authelia is running.

With this feature the minimal configuration requires
only two components: Authelia and nginx.

The users database is obviously made for development
environments only as it prevents Authelia to be scaled
to more than one instance.

Note: Configuration has been updated. Key `ldap` has
been nested in `authentication_backend`.
pull/263/head
Clément Michaud 2018-08-26 10:30:43 +02:00 committed by GitHub
parent 1f5a18d12a
commit 9dab40c2ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 865 additions and 193 deletions

2
.gitignore vendored
View File

@ -34,3 +34,5 @@ dist/
example/ldap/private.ldif
Configuration.schema.json
users_database.test.yml

View File

@ -2,17 +2,10 @@
# Authelia minimal configuration #
###############################################################
ldap:
url: ldap://openldap
base_dn: dc=example,dc=com
additional_users_dn: ou=users
additional_groups_dn: ou=groups
groups_filter: (&(member={dn})(objectclass=groupOfNames))
user: cn=admin,dc=example,dc=com
password: password
authentication_backend:
file:
# The path to the database file. The file is at the root of the repo.
path: /etc/authelia/users_database.yml
session:
# The secret to encrypt the session cookies with.

View File

@ -29,43 +29,61 @@ default_redirection_url: https://home.example.com:8080/
totp:
issuer: authelia.com
# LDAP configuration
# The authentication backend to use for verifying user passwords
# and retrieve information such as email address and groups
# users belong to.
#
# Example: for user john, the DN will be cn=john,ou=users,dc=example,dc=com
ldap:
# The url of the ldap server
url: ldap://openldap
# There are two supported backends: `ldap` and `file`.
authentication_backend:
# LDAP backend configuration.
#
# This backend allows Authelia to be scaled to more
# than one instance and therefore is recommended for
# production.
ldap:
# The url of the ldap server
url: ldap://openldap
# The base dn for every entries
base_dn: dc=example,dc=com
# The base dn for every entries
base_dn: dc=example,dc=com
# An additional dn to define the scope to all users
additional_users_dn: ou=users
# An additional dn to define the scope to all users
additional_users_dn: ou=users
# The users filter used to find the user DN
# {0} is a matcher replaced by username.
# 'cn={0}' by default.
users_filter: cn={0}
# The users filter used to find the user DN
# {0} is a matcher replaced by username.
# 'cn={0}' by default.
users_filter: cn={0}
# An additional dn to define the scope of groups
additional_groups_dn: ou=groups
# An additional dn to define the scope of groups
additional_groups_dn: ou=groups
# The groups filter used for retrieving groups of a given user.
# {0} is a matcher replaced by username.
# {dn} is a matcher replaced by user DN.
# 'member={dn}' by default.
groups_filter: (&(member={dn})(objectclass=groupOfNames))
# The groups filter used for retrieving groups of a given user.
# {0} is a matcher replaced by username.
# {dn} is a matcher replaced by user DN.
# 'member={dn}' by default.
groups_filter: (&(member={dn})(objectclass=groupOfNames))
# The attribute holding the name of the group
group_name_attribute: cn
# The attribute holding the name of the group
group_name_attribute: cn
# The attribute holding the mail address of the user
mail_attribute: mail
# The attribute holding the mail address of the user
mail_attribute: mail
# The username and password of the admin user.
user: cn=admin,dc=example,dc=com
password: password
# The username and password of the admin user.
user: cn=admin,dc=example,dc=com
password: password
# File backend configuration.
#
# With this backend, the users database is stored in a file
# which is updated when users reset their passwords.
# Therefore, this backend is meant to be used in a dev environment
# and not in production since it prevents Authelia to be scaled to
# more than one instance.
#
## file:
## path: ./users_database.yml
# Authentication methods
@ -87,6 +105,7 @@ authentication_methods:
per_subdomain_methods:
single_factor.example.com: single_factor
# Access Control
#
# Access control is a set of rules you can use to restrict user access to certain
@ -217,8 +236,8 @@ regulation:
# You must use only an available configuration: local, mongo
storage:
# The directory where the DB files will be saved
# local:
# path: /var/lib/authelia/store
## local:
## path: /var/lib/authelia/store
# Settings to connect to mongo server
mongo:
@ -232,16 +251,16 @@ storage:
# Use only an available configuration: filesystem, gmail
notifier:
# For testing purpose, notifications can be sent in a file
# filesystem:
# filename: /tmp/authelia/notification.txt
## filesystem:
## filename: /tmp/authelia/notification.txt
# Use your email account to send the notifications. You can use an app password.
# List of valid services can be found here: https://nodemailer.com/smtp/well-known/
# email:
# username: user@example.com
# password: yourpassword
# sender: admin@example.com
# service: gmail
## email:
## username: user@example.com
## password: yourpassword
## sender: admin@example.com
## service: gmail
# Use a SMTP server for sending notifications
smtp:

View File

@ -5,8 +5,9 @@ services:
restart: always
volumes:
- ./config.minimal.yml:/etc/authelia/config.yml:ro
- ./users_database.test.yml:/etc/authelia/users_database.yml:rw
- /tmp/authelia:/tmp/authelia
environment:
- NODE_TLS_REJECT_UNAUTHORIZED=0
networks:
- example-network
- example-network

View File

@ -42,11 +42,13 @@ describe("Server", function () {
domain: "example.com",
secret: "secret"
},
ldap: {
url: "http://ldap",
user: "user",
password: "password",
base_dn: "dc=example,dc=com"
authentication_backend: {
ldap: {
url: "http://ldap",
user: "user",
password: "password",
base_dn: "dc=example,dc=com"
},
},
notifier: {
email: {

View File

@ -35,7 +35,10 @@ export default class Server {
const displayableConfiguration: Configuration = clone(configuration);
const STARS = "*****";
displayableConfiguration.ldap.password = STARS;
if (displayableConfiguration.authentication_backend.ldap) {
displayableConfiguration.authentication_backend.ldap.password = STARS;
}
displayableConfiguration.session.secret = STARS;
if (displayableConfiguration.notifier && displayableConfiguration.notifier.email)
displayableConfiguration.notifier.email.password = STARS;

View File

@ -6,7 +6,7 @@ import { INotifier } from "./notifiers/INotifier";
import { IRegulator } from "./regulation/IRegulator";
import { Configuration } from "./configuration/schema/Configuration";
import { IAccessController } from "./access_control/IAccessController";
import { IUsersDatabase } from "./ldap/IUsersDatabase";
import { IUsersDatabase } from "./authentication/backends/IUsersDatabase";
export interface ServerVariables {
logger: IRequestLogger;

View File

@ -11,8 +11,8 @@ import { TotpHandler } from "./authentication/totp/TotpHandler";
import { ITotpHandler } from "./authentication/totp/ITotpHandler";
import { NotifierFactory } from "./notifiers/NotifierFactory";
import { MailSenderBuilder } from "./notifiers/MailSenderBuilder";
import { LdapUsersDatabase } from "./ldap/LdapUsersDatabase";
import { ConnectorFactory } from "./ldap/connector/ConnectorFactory";
import { LdapUsersDatabase } from "./authentication/backends/ldap/LdapUsersDatabase";
import { ConnectorFactory } from "./authentication/backends/ldap/connector/ConnectorFactory";
import { IUserDataStore } from "./storage/IUserDataStore";
import { UserDataStore } from "./storage/UserDataStore";
@ -32,7 +32,9 @@ import { ServerVariables } from "./ServerVariables";
import { MethodCalculator } from "./authentication/MethodCalculator";
import { MongoClient } from "./connectors/mongo/MongoClient";
import { IGlobalLogger } from "./logging/IGlobalLogger";
import { SessionFactory } from "./ldap/SessionFactory";
import { SessionFactory } from "./authentication/backends/ldap/SessionFactory";
import { IUsersDatabase } from "./authentication/backends/IUsersDatabase";
import { FileUsersDatabase } from "./authentication/backends/file/FileUsersDatabase";
class UserDataStoreFactory {
static create(config: Configuration.Configuration, globalLogger: IGlobalLogger): BluebirdPromise<UserDataStore> {
@ -58,24 +60,44 @@ class UserDataStoreFactory {
}
export class ServerVariablesInitializer {
static createUsersDatabase(
config: Configuration.Configuration,
deps: GlobalDependencies)
: IUsersDatabase {
if (config.authentication_backend.ldap) {
const ldapConfig = config.authentication_backend.ldap;
return new LdapUsersDatabase(
new SessionFactory(
ldapConfig,
new ConnectorFactory(ldapConfig, deps.ldapjs),
deps.winston
),
ldapConfig
);
}
else if (config.authentication_backend.file) {
return new FileUsersDatabase(config.authentication_backend.file);
}
}
static initialize(
config: Configuration.Configuration,
globalLogger: IGlobalLogger,
requestLogger: IRequestLogger,
deps: GlobalDependencies): BluebirdPromise<ServerVariables> {
deps: GlobalDependencies)
: BluebirdPromise<ServerVariables> {
const mailSenderBuilder = new MailSenderBuilder(Nodemailer);
const notifier = NotifierFactory.build(config.notifier, mailSenderBuilder);
const ldapUsersDatabase = new LdapUsersDatabase(
new SessionFactory(
config.ldap,
new ConnectorFactory(config.ldap, deps.ldapjs),
deps.winston
),
config.ldap
);
const accessController = new AccessController(config.access_control, deps.winston);
const totpHandler = new TotpHandler(deps.speakeasy);
const mailSenderBuilder =
new MailSenderBuilder(Nodemailer);
const notifier = NotifierFactory.build(
config.notifier, mailSenderBuilder);
const accessController = new AccessController(
config.access_control, deps.winston);
const totpHandler = new TotpHandler(
deps.speakeasy);
const usersDatabase = this.createUsersDatabase(
config, deps);
return UserDataStoreFactory.create(config, globalLogger)
.then(function (userDataStore: UserDataStore) {
@ -85,7 +107,7 @@ export class ServerVariablesInitializer {
const variables: ServerVariables = {
accessController: accessController,
config: config,
usersDatabase: ldapUsersDatabase,
usersDatabase: usersDatabase,
logger: requestLogger,
notifier: notifier,
regulator: regulator,

View File

@ -1,7 +1,7 @@
import { ServerVariables } from "./ServerVariables";
import { Configuration } from "./configuration/schema/Configuration";
import { UsersDatabaseStub } from "./ldap/UsersDatabaseStub.spec";
import { IUsersDatabaseStub } from "./authentication/backends/IUsersDatabaseStub.spec";
import { AccessControllerStub } from "./access_control/AccessControllerStub.spec";
import { RequestLoggerStub } from "./logging/RequestLoggerStub.spec";
import { NotifierStub } from "./notifiers/NotifierStub.spec";
@ -13,7 +13,7 @@ import { U2fHandlerStub } from "./authentication/u2f/U2fHandlerStub.spec";
export interface ServerVariablesMock {
accessController: AccessControllerStub;
config: Configuration;
usersDatabase: UsersDatabaseStub;
usersDatabase: IUsersDatabaseStub;
logger: RequestLoggerStub;
notifier: NotifierStub;
regulator: RegulatorStub;
@ -34,17 +34,19 @@ export class ServerVariablesMockBuilder {
totp: {
issuer: "authelia.com"
},
ldap: {
url: "ldap://ldap",
base_dn: "dc=example,dc=com",
user: "user",
password: "password",
mail_attribute: "mail",
additional_users_dn: "ou=users",
additional_groups_dn: "ou=groups",
users_filter: "cn={0}",
groups_filter: "member={dn}",
group_name_attribute: "cn"
authentication_backend: {
ldap: {
url: "ldap://ldap",
base_dn: "dc=example,dc=com",
user: "user",
password: "password",
mail_attribute: "mail",
additional_users_dn: "ou=users",
additional_groups_dn: "ou=groups",
users_filter: "cn={0}",
groups_filter: "member={dn}",
group_name_attribute: "cn"
},
},
logs_level: "debug",
notifier: {},
@ -60,7 +62,7 @@ export class ServerVariablesMockBuilder {
},
storage: {}
},
usersDatabase: new UsersDatabaseStub(),
usersDatabase: new IUsersDatabaseStub(),
logger: new RequestLoggerStub(enableLogging),
notifier: new NotifierStub(),
regulator: new RegulatorStub(),

View File

@ -0,0 +1,5 @@
export interface GroupsAndEmails {
groups: string[];
emails: string[];
}

View File

@ -1,5 +1,6 @@
import Bluebird = require("bluebird");
import { GroupsAndEmails } from "./ISession";
import { GroupsAndEmails } from "./GroupsAndEmails";
export interface IUsersDatabase {
checkUserPassword(username: string, password: string): Bluebird<GroupsAndEmails>;

View File

@ -2,9 +2,9 @@ import Bluebird = require("bluebird");
import Sinon = require("sinon");
import { IUsersDatabase } from "./IUsersDatabase";
import { GroupsAndEmails } from "./ISession";
import { GroupsAndEmails } from "./GroupsAndEmails";
export class UsersDatabaseStub implements IUsersDatabase {
export class IUsersDatabaseStub implements IUsersDatabase {
checkUserPasswordStub: Sinon.SinonStub;
getEmailsStub: Sinon.SinonStub;
getGroupsStub: Sinon.SinonStub;

View File

@ -0,0 +1,224 @@
import Assert = require("assert");
import Bluebird = require("bluebird");
import Fs = require("fs");
import Sinon = require("sinon");
import Tmp = require("tmp");
import { FileUsersDatabase } from "./FileUsersDatabase";
import { FileUsersDatabaseConfiguration } from "../../../configuration/schema/FileUsersDatabaseConfiguration";
import { HashGenerator } from "../../../utils/HashGenerator";
const GOOD_DATABASE = `
users:
john:
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
email: john.doe@authelia.com
groups:
- admins
- dev
harry:
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
emails: harry.potter@authelia.com
groups: []
`;
const BAD_HASH = `
users:
john:
password: "{CRYPT}$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
email: john.doe@authelia.com
groups:
- admins
- dev
`;
const NO_PASSWORD_DATABASE = `
users:
john:
email: john.doe@authelia.com
groups:
- admins
- dev
`;
const NO_EMAIL_DATABASE = `
users:
john:
password: "{CRYPT}$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
groups:
- admins
- dev
`;
const SINGLE_USER_DATABASE = `
users:
john:
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
email: john.doe@authelia.com
groups:
- admins
- dev
`
function createTmpFileFrom(yaml: string) {
const tmpFileAsync = Bluebird.promisify(Tmp.file);
return tmpFileAsync()
.then((path: string) => {
Fs.writeFileSync(path, yaml, "utf-8");
return Bluebird.resolve(path);
});
}
describe("authentication/backends/file/FileUsersDatabase", function() {
let configuration: FileUsersDatabaseConfiguration;
describe("checkUserPassword", () => {
describe("good config", () => {
beforeEach(() => {
return createTmpFileFrom(GOOD_DATABASE)
.then((path: string) => configuration = {
path: path
});
});
it("should succeed", () => {
const usersDatabase = new FileUsersDatabase(configuration);
return usersDatabase.checkUserPassword("john", "password")
.then((groupsAndEmails) => {
Assert.deepEqual(groupsAndEmails.groups, ["admins", "dev"]);
Assert.deepEqual(groupsAndEmails.emails, ["john.doe@authelia.com"]);
});
});
it("should fail when password is wrong", () => {
const usersDatabase = new FileUsersDatabase(configuration);
return usersDatabase.checkUserPassword("john", "bad_password")
.then(() => Bluebird.reject(new Error("should not be here.")))
.catch((err) => {
return Bluebird.resolve();
});
});
it("should fail when user does not exist", () => {
const usersDatabase = new FileUsersDatabase(configuration);
return usersDatabase.checkUserPassword("no_user", "password")
.then(() => Bluebird.reject(new Error("should not be here.")))
.catch((err) => {
return Bluebird.resolve();
});
});
});
describe("bad hash", () => {
beforeEach(() => {
return createTmpFileFrom(GOOD_DATABASE)
.then((path: string) => configuration = {
path: path
});
});
it("should fail when hash is wrong", () => {
const usersDatabase = new FileUsersDatabase(configuration);
return usersDatabase.checkUserPassword("john", "password")
.then(() => Bluebird.reject(new Error("should not be here.")))
.catch((err) => {
return Bluebird.resolve();
});
});
});
describe("no password", () => {
beforeEach(() => {
return createTmpFileFrom(NO_PASSWORD_DATABASE)
.then((path: string) => configuration = {
path: path
});
});
it("should fail", () => {
const usersDatabase = new FileUsersDatabase(configuration);
return usersDatabase.checkUserPassword("john", "password")
.then(() => Bluebird.reject(new Error("should not be here.")))
.catch((err) => {
return Bluebird.resolve();
});
});
});
});
describe("getEmails", () => {
describe("good config", () => {
beforeEach(() => {
return createTmpFileFrom(GOOD_DATABASE)
.then((path: string) => configuration = {
path: path
});
});
it("should succeed", () => {
const usersDatabase = new FileUsersDatabase(configuration);
return usersDatabase.getEmails("john")
.then((emails) => {
Assert.deepEqual(emails, ["john.doe@authelia.com"]);
});
});
it("should fail when user does not exist", () => {
const usersDatabase = new FileUsersDatabase(configuration);
return usersDatabase.getEmails("no_user")
.then(() => Bluebird.reject(new Error("should not be here.")))
.catch((err) => {
return Bluebird.resolve();
});
});
});
describe("no email provided", () => {
beforeEach(() => {
return createTmpFileFrom(NO_EMAIL_DATABASE)
.then((path: string) => configuration = {
path: path
});
});
it("should fail", () => {
const usersDatabase = new FileUsersDatabase(configuration);
return usersDatabase.getEmails("john")
.then(() => Bluebird.reject(new Error("should not be here.")))
.catch((err) => {
return Bluebird.resolve();
});
});
});
});
describe("updatePassword", () => {
beforeEach(() => {
return createTmpFileFrom(SINGLE_USER_DATABASE)
.then((path: string) => configuration = {
path: path
});
});
it("should succeed", () => {
const usersDatabase = new FileUsersDatabase(configuration);
const NEW_HASH = "{CRYPT}$6$rounds=500000$Qw6MhgADvLyYMEq9$ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const stub = Sinon.stub(HashGenerator, "ssha512").returns(Bluebird.resolve(NEW_HASH));
return usersDatabase.updatePassword("john", "mypassword")
.then(() => {
const content = Fs.readFileSync(configuration.path, "utf-8");
const matches = content.match(/password: '(.+)'/);
Assert.equal(matches[1], NEW_HASH);
})
.finally(() => stub.restore());
});
it("should fail when user does not exist", () => {
const usersDatabase = new FileUsersDatabase(configuration);
return usersDatabase.updatePassword("bad_user", "mypassword")
.then(() => Bluebird.reject(new Error("should not be here")))
.catch(() => Bluebird.resolve());
});
});
});

View File

@ -0,0 +1,182 @@
import Bluebird = require("bluebird");
import Fs = require("fs");
import Yaml = require("yamljs");
import { FileUsersDatabaseConfiguration }
from "../../../configuration/schema/FileUsersDatabaseConfiguration";
import { GroupsAndEmails } from "../GroupsAndEmails";
import { IUsersDatabase } from "../IUsersDatabase";
import { HashGenerator } from "../../../utils/HashGenerator";
import { ReadWriteQueue } from "./ReadWriteQueue";
const loadAsync = Bluebird.promisify(Yaml.load);
export class FileUsersDatabase implements IUsersDatabase {
private configuration: FileUsersDatabaseConfiguration;
private queue: ReadWriteQueue;
constructor(configuration: FileUsersDatabaseConfiguration) {
this.configuration = configuration;
this.queue = new ReadWriteQueue(this.configuration.path);
}
/**
* Read database from file.
* It enqueues the read task so that it is scheduled
* between other reads and writes.
*/
private readDatabase(): Bluebird<any> {
return new Bluebird<string>((resolve, reject) => {
this.queue.read((err: Error, data: string) => {
if (err) {
reject(err);
return;
}
resolve(data);
this.queue.next();
});
})
.then((content) => {
const database = Yaml.parse(content);
if (!database) {
return Bluebird.reject(new Error("Unable to parse YAML file."));
}
return Bluebird.resolve(database);
});
}
/**
* Checks the user exists in the database.
*/
private checkUserExists(
database: any,
username: string)
: Bluebird<void> {
if (!(username in database.users)) {
return Bluebird.reject(
new Error(`User ${username} does not exist in database.`));
}
return Bluebird.resolve();
}
/**
* Check the password of a given user.
*/
private checkPassword(
database: any,
username: string,
password: string)
: Bluebird<void> {
const storedHash: string = database.users[username].password;
const matches = storedHash.match(/rounds=([0-9]+)\$([a-zA-z0-9]+)\$/);
if (!(matches && matches.length == 3)) {
return Bluebird.reject(new Error("Unable to detect the hash salt and rounds. " +
"Make sure the password is hashed with SSHA512."));
}
const rounds: number = parseInt(matches[1]);
const salt = matches[2];
return HashGenerator.ssha512(password, rounds, salt)
.then((hash: string) => {
if (hash !== storedHash) {
return Bluebird.reject(new Error("Wrong username/password."));
}
return Bluebird.resolve();
});
}
/**
* Retrieve email addresses of a given user.
*/
private retrieveEmails(
database: any,
username: string)
: Bluebird<string[]> {
if (!("email" in database.users[username])) {
return Bluebird.reject(
new Error(`User ${username} has no email address.`));
}
return Bluebird.resolve(
[database.users[username].email]);
}
private retrieveGroups(
database: any,
username: string)
: Bluebird<string[]> {
if (!("groups" in database.users[username])) {
return Bluebird.resolve([]);
}
return Bluebird.resolve(
database.users[username].groups);
}
private replacePassword(
database: any,
username: string,
newPassword: string)
: Bluebird<void> {
const that = this;
return HashGenerator.ssha512(newPassword)
.then((hash) => {
database.users[username].password = hash;
const str = Yaml.stringify(database, 4, 2);
return Bluebird.resolve(str);
})
.then((content: string) => {
return new Bluebird((resolve, reject) => {
that.queue.write(content, (err) => {
if (err) {
return reject(err);
}
resolve();
that.queue.next();
});
});
});
}
checkUserPassword(
username: string,
password: string)
: Bluebird<GroupsAndEmails> {
return this.readDatabase()
.then((database) => {
return this.checkUserExists(database, username)
.then(() => this.checkPassword(database, username, password))
.then(() => {
return Bluebird.join(
this.retrieveEmails(database, username),
this.retrieveGroups(database, username)
).spread((emails: string[], groups: string[]) => {
return { emails: emails, groups: groups };
});
});
});
}
getEmails(username: string): Bluebird<string[]> {
return this.readDatabase()
.then((database) => {
return this.checkUserExists(database, username)
.then(() => this.retrieveEmails(database, username));
});
}
getGroups(username: string): Bluebird<string[]> {
return this.readDatabase()
.then((database) => {
return this.checkUserExists(database, username)
.then(() => this.retrieveGroups(database, username));
});
}
updatePassword(username: string, newPassword: string): Bluebird<void> {
return this.readDatabase()
.then((database) => {
return this.checkUserExists(database, username)
.then(() => this.replacePassword(database, username, newPassword));
});
}
}

View File

@ -0,0 +1,60 @@
import Fs = require("fs");
type Callback = (err: Error, data?: string) => void;
type ContentAndCallback = [string, Callback] | [string, string, Callback];
/**
* WriteQueue is a queue synchronizing writes to a file.
*
* Example of use:
*
* queue.add(mycontent, (err) => {
* // do whatever you want here.
* queue.next();
* })
*/
export class ReadWriteQueue {
private filePath: string;
private queue: ContentAndCallback[];
constructor (filePath: string) {
this.queue = [];
this.filePath = filePath;
}
next () {
if (this.queue.length === 0)
return;
const task = this.queue[0];
if (task[0] == "write") {
Fs.writeFile(this.filePath, task[1], "utf-8", (err) => {
this.queue.shift();
const cb = task[2] as Callback;
cb(err);
});
}
else if (task[0] == "read") {
Fs.readFile(this.filePath, { encoding: "utf-8"} , (err, data) => {
this.queue.shift();
const cb = task[1] as Callback;
cb(err, data);
});
}
}
write (content: string, cb: Callback) {
this.queue.push(["write", content, cb]);
if (this.queue.length === 1) {
this.next();
}
}
read (cb: Callback) {
this.queue.push(["read", cb]);
if (this.queue.length === 1) {
this.next();
}
}
}

View File

@ -1,11 +1,6 @@
import BluebirdPromise = require("bluebird");
export interface GroupsAndEmails {
groups: string[];
emails: string[];
}
export interface ISession {
open(): BluebirdPromise<void>;
close(): BluebirdPromise<void>;

View File

@ -1,9 +1,10 @@
import Bluebird = require("bluebird");
import { IUsersDatabase } from "./IUsersDatabase";
import { IUsersDatabase } from "../IUsersDatabase";
import { ISessionFactory } from "./ISessionFactory";
import { LdapConfiguration } from "../configuration/schema/LdapConfiguration";
import { ISession, GroupsAndEmails } from "./ISession";
import Exceptions = require("../Exceptions");
import { LdapConfiguration } from "../../../configuration/schema/LdapConfiguration";
import { ISession } from "./ISession";
import { GroupsAndEmails } from "../GroupsAndEmails";
import Exceptions = require("../../../Exceptions");
type SessionCallback<T> = (session: ISession) => Bluebird<T>;

View File

@ -1,5 +1,5 @@
import BluebirdPromise = require("bluebird");
import { ISession, GroupsAndEmails } from "./ISession";
import { ISession } from "./ISession";
import { Sanitizer } from "./Sanitizer";
const SPECIAL_CHAR_USED_MESSAGE = "Special character used in LDAP query.";

View File

@ -1,5 +1,5 @@
import { LdapConfiguration } from "../configuration/schema/LdapConfiguration";
import { LdapConfiguration } from "../../../configuration/schema/LdapConfiguration";
import { Session } from "./Session";
import { ConnectorFactoryStub } from "./connector/ConnectorFactoryStub.spec";
import { ConnectorStub } from "./connector/ConnectorStub.spec";

View File

@ -1,11 +1,11 @@
import BluebirdPromise = require("bluebird");
import exceptions = require("../Exceptions");
import exceptions = require("../../../Exceptions");
import { EventEmitter } from "events";
import { ISession, GroupsAndEmails } from "./ISession";
import { LdapConfiguration } from "../configuration/schema/LdapConfiguration";
import { Winston } from "../../../types/Dependencies";
import { ISession } from "./ISession";
import { LdapConfiguration } from "../../../configuration/schema/LdapConfiguration";
import { Winston } from "../../../../../types/Dependencies";
import Util = require("util");
import { HashGenerator } from "../utils/HashGenerator";
import { HashGenerator } from "../../../utils/HashGenerator";
import { IConnector } from "./connector/IConnector";
export class Session implements ISession {

View File

@ -4,7 +4,7 @@ import Winston = require("winston");
import { IConnectorFactory } from "./connector/IConnectorFactory";
import { ISessionFactory } from "./ISessionFactory";
import { ISession } from "./ISession";
import { LdapConfiguration } from "../configuration/schema/LdapConfiguration";
import { LdapConfiguration } from "../../../configuration/schema/LdapConfiguration";
import { Session } from "./Session";
import { SafeSession } from "./SafeSession";

View File

@ -1,7 +1,7 @@
import Bluebird = require("bluebird");
import Sinon = require("sinon");
import { ISession, GroupsAndEmails } from "./ISession";
import { ISession } from "./ISession";
export class SessionStub implements ISession {
openStub: Sinon.SinonStub;

View File

@ -2,7 +2,7 @@ import LdapJs = require("ldapjs");
import EventEmitter = require("events");
import Bluebird = require("bluebird");
import { IConnector } from "./IConnector";
import Exceptions = require("../../Exceptions");
import Exceptions = require("../../../../Exceptions");
interface SearchEntry {
object: any;

View File

@ -1,6 +1,6 @@
import { IConnector } from "./IConnector";
import { Connector } from "./Connector";
import { LdapConfiguration } from "../../configuration/schema/LdapConfiguration";
import { LdapConfiguration } from "../../../../configuration/schema/LdapConfiguration";
import { Ldapjs } from "Dependencies";
export class ConnectorFactory {

View File

@ -1,5 +1,6 @@
import Sinon = require("sinon");
import BluebirdPromise = require("bluebird");
import Sinon = require("sinon");
import { IConnectorFactory } from "./IConnectorFactory";
import { IConnector } from "./IConnector";

View File

@ -1,5 +1,6 @@
import Sinon = require("sinon");
import BluebirdPromise = require("bluebird");
import Sinon = require("sinon");
import { IConnector } from "./IConnector";
export class ConnectorStub implements IConnector {

View File

@ -1,4 +1,3 @@
import Bluebird = require("bluebird");
import EventEmitter = require("events");

View File

@ -7,13 +7,15 @@ describe("configuration/ConfigurationParser", function () {
function buildYamlConfig(): Configuration {
const yaml_config: Configuration = {
port: 8080,
ldap: {
url: "http://ldap",
base_dn: "dc=example,dc=com",
additional_users_dn: "ou=users",
additional_groups_dn: "ou=groups",
user: "user",
password: "pass"
authentication_backend: {
ldap: {
url: "http://ldap",
base_dn: "dc=example,dc=com",
additional_users_dn: "ou=users",
additional_groups_dn: "ou=groups",
user: "user",
password: "pass"
},
},
session: {
domain: "example.com",

View File

@ -19,17 +19,19 @@ describe("configuration/SessionConfigurationBuilder", function () {
totp: {
issuer: "authelia.com"
},
ldap: {
url: "ldap://ldap",
user: "user",
base_dn: "dc=example,dc=com",
password: "password",
additional_groups_dn: "ou=groups",
additional_users_dn: "ou=users",
group_name_attribute: "",
groups_filter: "",
mail_attribute: "",
users_filter: ""
authentication_backend: {
ldap: {
url: "ldap://ldap",
user: "user",
base_dn: "dc=example,dc=com",
password: "password",
additional_groups_dn: "ou=groups",
additional_users_dn: "ou=users",
group_name_attribute: "",
groups_filter: "",
mail_attribute: "",
users_filter: ""
},
},
logs_level: "debug",
notifier: {
@ -100,17 +102,19 @@ describe("configuration/SessionConfigurationBuilder", function () {
totp: {
issuer: "authelia.com"
},
ldap: {
url: "ldap://ldap",
user: "user",
password: "password",
base_dn: "dc=example,dc=com",
additional_groups_dn: "ou=groups",
additional_users_dn: "ou=users",
group_name_attribute: "",
groups_filter: "",
mail_attribute: "",
users_filter: ""
authentication_backend: {
ldap: {
url: "ldap://ldap",
user: "user",
password: "password",
base_dn: "dc=example,dc=com",
additional_groups_dn: "ou=groups",
additional_users_dn: "ou=users",
group_name_attribute: "",
groups_filter: "",
mail_attribute: "",
users_filter: ""
},
},
logs_level: "debug",
notifier: {

View File

@ -0,0 +1,11 @@
import { AuthenticationBackendConfiguration, complete } from "./AuthenticationBackendConfiguration";
import Assert = require("assert");
describe("configuration/schema/AuthenticationBackendConfiguration", function() {
it("should ensure there is at least one key", function() {
const configuration: AuthenticationBackendConfiguration = {} as any;
const [newConfiguration, error] = complete(configuration);
Assert.equal(error, "Authentication backend must have one of the following keys:`ldap` or `file`");
});
});

View File

@ -0,0 +1,25 @@
import { LdapConfiguration } from "./LdapConfiguration";
import { FileUsersDatabaseConfiguration } from "./FileUsersDatabaseConfiguration";
export interface AuthenticationBackendConfiguration {
ldap?: LdapConfiguration;
file?: FileUsersDatabaseConfiguration;
}
export function complete(
configuration: AuthenticationBackendConfiguration)
: [AuthenticationBackendConfiguration, string] {
const newConfiguration: AuthenticationBackendConfiguration = (configuration)
? JSON.parse(JSON.stringify(configuration)) : {};
if (Object.keys(newConfiguration).length != 1) {
return [
newConfiguration,
"Authentication backend must have one of the following keys:" +
"`ldap` or `file`"
];
}
return [newConfiguration, undefined];
}

View File

@ -1,6 +1,6 @@
import { ACLConfiguration, complete as AclConfigurationComplete } from "./AclConfiguration";
import { AuthenticationMethodsConfiguration, complete as AuthenticationMethodsConfigurationComplete } from "./AuthenticationMethodsConfiguration";
import { LdapConfiguration, complete as LdapConfigurationComplete } from "./LdapConfiguration";
import { AuthenticationBackendConfiguration, complete as AuthenticationBackendComplete } from "./AuthenticationBackendConfiguration";
import { NotifierConfiguration, complete as NotifierConfigurationComplete } from "./NotifierConfiguration";
import { RegulationConfiguration, complete as RegulationConfigurationComplete } from "./RegulationConfiguration";
import { SessionConfiguration, complete as SessionConfigurationComplete } from "./SessionConfiguration";
@ -10,7 +10,7 @@ import { MethodCalculator } from "../../authentication/MethodCalculator";
export interface Configuration {
access_control?: ACLConfiguration;
ldap: LdapConfiguration;
authentication_backend: AuthenticationBackendConfiguration;
authentication_methods?: AuthenticationMethodsConfiguration;
default_redirection_url?: string;
logs_level?: string;
@ -30,10 +30,16 @@ export function complete(
JSON.stringify(configuration));
const errors: string[] = [];
newConfiguration.access_control = AclConfigurationComplete(
newConfiguration.access_control);
newConfiguration.ldap = LdapConfigurationComplete(
newConfiguration.ldap);
newConfiguration.access_control =
AclConfigurationComplete(
newConfiguration.access_control);
const [backend, error] =
AuthenticationBackendComplete(
newConfiguration.authentication_backend);
if (error) errors.push(error);
newConfiguration.authentication_backend = backend;
newConfiguration.authentication_methods =
AuthenticationMethodsConfigurationComplete(

View File

@ -0,0 +1,4 @@
export interface FileUsersDatabaseConfiguration {
path: string;
}

View File

@ -14,7 +14,7 @@ import UserMessages = require("../../../../../shared/UserMessages");
import { MethodCalculator } from "../../authentication/MethodCalculator";
import { ServerVariables } from "../../ServerVariables";
import { AuthenticationSession } from "../../../../types/AuthenticationSession";
import { GroupsAndEmails } from "../../ldap/ISession";
import { GroupsAndEmails } from "../../authentication/backends/GroupsAndEmails";
export default function (vars: ServerVariables) {
return function (req: express.Request, res: express.Response)

View File

@ -42,7 +42,7 @@ describe("routes/password-reset/form/post", function () {
mocks.userDataStore.produceIdentityValidationTokenStub.returns(BluebirdPromise.resolve({}));
mocks.userDataStore.consumeIdentityValidationTokenStub.returns(BluebirdPromise.resolve({}));
mocks.config.ldap = {
mocks.config.authentication_backend.ldap = {
url: "ldap://ldapjs",
mail_attribute: "mail",
user: "user",

View File

@ -8,7 +8,7 @@ import { IdentityValidable } from "../../../IdentityValidable";
import { PRE_VALIDATION_TEMPLATE } from "../../../IdentityCheckPreValidationTemplate";
import Constants = require("../constants");
import { IRequestLogger } from "../../../logging/IRequestLogger";
import { IUsersDatabase } from "../../../ldap/IUsersDatabase";
import { IUsersDatabase } from "../../../authentication/backends/IUsersDatabase";
export const TEMPLATE_NAME = "password-reset-form";

View File

@ -3,14 +3,14 @@ import { HashGenerator } from "./HashGenerator";
describe("utils/HashGenerator", function () {
it("should compute correct ssha512 (password)", function () {
return HashGenerator.ssha512("password", "jgiCMRyGXzoqpxS3")
return HashGenerator.ssha512("password", 500000, "jgiCMRyGXzoqpxS3")
.then(function (hash: string) {
Assert.equal(hash, "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/");
});
});
it("should compute correct ssha512 (test)", function () {
return HashGenerator.ssha512("test", "abcdefghijklmnop")
return HashGenerator.ssha512("test", 500000, "abcdefghijklmnop")
.then(function (hash: string) {
Assert.equal(hash, "{CRYPT}$6$rounds=500000$abcdefghijklmnop$sTlNGf0VO/HTQIOXemmaBbV28HUch/qhWOA1/4dsDj6CDQYhUgXbYSPL6gccAsWMr2zD5fFWwhKmPdG.yxphs.");
});

View File

@ -4,8 +4,10 @@ import Util = require("util");
const crypt = require("crypt3");
export class HashGenerator {
static ssha512(password: string, salt?: string): BluebirdPromise<string> {
const rounds = 500000;
static ssha512(
password: string,
rounds: number = 500000,
salt?: string): BluebirdPromise<string> {
const saltSize = 16;
// $6 means SHA512
const _salt = Util.format("$6$rounds=%d$%s", rounds,

View File

@ -0,0 +1,10 @@
import SeleniumWebdriver = require("selenium-webdriver");
export default function(driver: any, linkText: string) {
return driver.wait(
SeleniumWebdriver.until.elementLocated(
SeleniumWebdriver.By.linkText(linkText)), 5000)
.then(function (el) {
return el.click();
});
};

View File

@ -0,0 +1,10 @@
import SeleniumWebdriver = require("selenium-webdriver");
export default function(driver: any, fieldName: string, text: string) {
return driver.wait(
SeleniumWebdriver.until.elementLocated(
SeleniumWebdriver.By.name(fieldName)), 5000)
.then(function (el) {
return el.sendKeys(text);
});
};

View File

@ -0,0 +1,34 @@
import Bluebird = require("bluebird");
import Fs = require("fs");
import Request = require("request-promise");
export function GetLinkFromFile(): Bluebird<string> {
return Bluebird.promisify(Fs.readFile)("/tmp/authelia/notification.txt")
.then(function (data: any) {
const regexp = new RegExp(/Link: (.+)/);
const match = regexp.exec(data);
const link = match[1];
return Bluebird.resolve(link);
});
};
export function GetLinkFromEmail(): Bluebird<string> {
return Request({
method: "GET",
uri: "http://localhost:8085/messages",
json: true
})
.then(function (data: any) {
const messageId = data[data.length - 1].id;
return Request({
method: "GET",
uri: `http://localhost:8085/messages/${messageId}.html`
});
})
.then(function (data: any) {
const regexp = new RegExp(/<a href="(.+)" class="button">Continue<\/a>/);
const match = regexp.exec(data);
const link = match[1];
return Bluebird.resolve(link);
});
};

View File

@ -1,38 +1,6 @@
import Bluebird = require("bluebird");
import SeleniumWebdriver = require("selenium-webdriver");
import Fs = require("fs");
import Request = require("request-promise");
function retrieveValidationLinkFromNotificationFile(): Bluebird<string> {
return Bluebird.promisify(Fs.readFile)("/tmp/authelia/notification.txt")
.then(function (data: any) {
const regexp = new RegExp(/Link: (.+)/);
const match = regexp.exec(data);
const link = match[1];
return Bluebird.resolve(link);
});
};
function retrieveValidationLinkFromEmail(): Bluebird<string> {
return Request({
method: "GET",
uri: "http://localhost:8085/messages",
json: true
})
.then(function (data: any) {
const messageId = data[data.length - 1].id;
return Request({
method: "GET",
uri: `http://localhost:8085/messages/${messageId}.html`
});
})
.then(function (data: any) {
const regexp = new RegExp(/<a href="(.+)" class="button">Continue<\/a>/);
const match = regexp.exec(data);
const link = match[1];
return Bluebird.resolve(link);
});
};
import {GetLinkFromFile, GetLinkFromEmail} from '../helpers/get-identity-link';
export default function(driver: any, email?: boolean): Bluebird<string> {
return driver.wait(SeleniumWebdriver.until.elementLocated(SeleniumWebdriver.By.className("register-totp")), 5000)
@ -40,8 +8,8 @@ export default function(driver: any, email?: boolean): Bluebird<string> {
return driver.findElement(SeleniumWebdriver.By.className("register-totp")).click();
})
.then(function () {
if(email) return retrieveValidationLinkFromEmail();
else return retrieveValidationLinkFromNotificationFile();
if(email) return GetLinkFromEmail();
else return GetLinkFromFile();
})
.then(function (link: string) {
return driver.get(link);

View File

@ -1,21 +1,32 @@
require("chromedriver");
import ChildProcess = require('child_process');
import Bluebird = require("bluebird");
import Environment = require('../environment');
const execAsync = Bluebird.promisify(ChildProcess.exec);
const includes = [
"docker-compose.minimal.yml",
"example/compose/docker-compose.base.yml",
"example/compose/nginx/minimal/docker-compose.yml",
"example/compose/ldap/docker-compose.yml"
]
before(function() {
this.timeout(20000);
this.environment = new Environment.Environment(includes);
return this.environment.setup(2000);
return execAsync("cp users_database.yml users_database.test.yml")
.then(() => this.environment.setup(2000));
});
after(function() {
this.timeout(30000);
return this.environment.cleanup();
return execAsync("rm users_database.test.yml")
.then(() => {
if(process.env.KEEP_ENV != "true") {
return this.environment.cleanup();
}
});
});

View File

@ -0,0 +1,43 @@
require("chromedriver");
import Bluebird = require("bluebird");
import ChildProcess = require("child_process");
import SeleniumWebdriver = require("selenium-webdriver");
import WithDriver from '../helpers/with-driver';
import VisitPage from '../helpers/visit-page';
import ClickOnLink from '../helpers/click-on-link';
import ClickOnButton from '../helpers/click-on-button';
import WaitRedirect from '../helpers/wait-redirected';
import FillField from "../helpers/fill-field";
import {GetLinkFromFile} from "../helpers/get-identity-link";
import FillLoginPageAndClick from "../helpers/fill-login-page-and-click";
const execAsync = Bluebird.promisify(ChildProcess.exec);
describe('Reset password', function() {
this.timeout(10000);
WithDriver();
after(() => {
return execAsync("cp users_database.yml users_database.test.yml");
})
describe('click on reset password', function() {
it("should reset password for john", function() {
return VisitPage(this.driver, "https://login.example.com:8080/")
.then(() => ClickOnLink(this.driver, "Forgot password\?"))
.then(() => WaitRedirect(this.driver, "https://login.example.com:8080/password-reset/request"))
.then(() => FillField(this.driver, "username", "john"))
.then(() => ClickOnButton(this.driver, "Reset Password"))
.then(() => this.driver.sleep(1000)) // Simulate the time to read it from mailbox.
.then(() => GetLinkFromFile())
.then((link) => VisitPage(this.driver, link))
.then(() => FillField(this.driver, "password1", "newpass"))
.then(() => FillField(this.driver, "password2", "newpass"))
.then(() => ClickOnButton(this.driver, "Reset Password"))
.then(() => WaitRedirect(this.driver, "https://login.example.com:8080/"))
.then(() => FillLoginPageAndClick(this.driver, "john", "newpass"))
.then(() => WaitRedirect(this.driver, "https://login.example.com:8080/secondfactor"))
});
});
});

29
users_database.yml 100644
View File

@ -0,0 +1,29 @@
###############################################################
# Users Database #
###############################################################
# This file can be used if you do not have an LDAP set up.
# List of users
users:
john:
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
email: john.doe@authelia.com
groups:
- admins
- dev
harry:
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
emails: harry.potter@authelia.com
groups: []
bob:
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
email: bob.dylan@authelia.com
groups:
- dev
james:
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
email: james.dean@authelia.com