From dedd712039620c096d17798a9dfabaffc679c8f1 Mon Sep 17 00:00:00 2001 From: BankaiNoJutsu Date: Tue, 18 Dec 2018 07:47:07 +0100 Subject: [PATCH] added black theme and fixed main css matrix.js (not needed) --- Gruntfile.js | 33 +- themes/black/client/src/css/.directory | 4 + .../black/client/src/css/00-bootstrap.min.css | 5768 +++++++++++++++++ themes/black/client/src/css/01-main.css | 77 + themes/black/client/src/css/02-login.css | 136 + themes/black/client/src/css/03-errors.css | 12 + .../client/src/css/03-password-reset-form.css | 4 + .../src/css/03-password-reset-request.css | 4 + .../black/client/src/css/03-totp-register.css | 22 + .../black/client/src/css/03-u2f-register.css | 5 + .../client/src/img/RandomizedPattern.svg | 1 + themes/black/client/src/img/background.jpg | Bin 0 -> 587 bytes themes/black/client/src/img/icon.png | Bin 0 -> 1461 bytes themes/black/client/src/img/mail.png | Bin 0 -> 3545 bytes .../client/src/img/notifications/.directory | 4 + .../client/src/img/notifications/error.png | Bin 0 -> 863 bytes .../client/src/img/notifications/info.png | Bin 0 -> 732 bytes .../client/src/img/notifications/success.png | Bin 0 -> 931 bytes .../client/src/img/notifications/warning.png | Bin 0 -> 580 bytes themes/black/client/src/img/padlock.png | Bin 0 -> 3265 bytes .../black/client/src/img/password_white.png | Bin 0 -> 3858 bytes themes/black/client/src/img/pendrive.png | Bin 0 -> 6721 bytes themes/black/client/src/img/sharingan.png | Bin 0 -> 9213 bytes themes/black/client/src/img/stores/.directory | 4 + .../src/img/stores/applestore-badge.svg | 129 + .../src/img/stores/googleplay-badge.svg | 429 ++ themes/black/client/src/img/success.png | Bin 0 -> 3147 bytes themes/black/client/src/img/user.png | Bin 0 -> 2933 bytes themes/black/client/src/img/warning.png | Bin 0 -> 4038 bytes themes/black/client/src/index.ts | 34 + themes/black/client/src/lib/GetPromised.ts | 14 + themes/black/client/src/lib/INotifier.ts | 14 + themes/black/client/src/lib/Notifier.ts | 83 + .../src/lib/QueryParametersRetriever.ts | 12 + themes/black/client/src/lib/SafeRedirect.ts | 10 + .../lib/firstfactor/FirstFactorValidator.ts | 46 + .../client/src/lib/firstfactor/UISelectors.ts | 5 + .../black/client/src/lib/firstfactor/index.ts | 49 + .../src/lib/reset-password/constants.ts | 2 + .../lib/reset-password/reset-password-form.ts | 57 + .../reset-password/reset-password-request.ts | 56 + .../src/lib/secondfactor/TOTPValidator.ts | 28 + .../src/lib/secondfactor/U2FValidator.ts | 42 + .../client/src/lib/secondfactor/constants.ts | 3 + .../client/src/lib/secondfactor/index.ts | 59 + .../src/lib/totp-register/totp-register.ts | 11 + .../src/lib/totp-register/ui-selector.ts | 2 + .../src/lib/u2f-register/u2f-register.ts | 56 + .../client/src/thirdparties/qrcode.min.js | 1 + .../black/client/src/thirdparties/u2f-api.js | 749 +++ themes/black/client/test/Notifier.test.ts | 71 + .../firstfactor/FirstFactorValidator.test.ts | 44 + .../black/client/test/mocks/NotifierStub.ts | 33 + themes/black/client/test/mocks/jquery.ts | 59 + themes/black/client/test/mocks/u2f-api.ts | 14 + .../test/secondfactor/TOTPValidator.test.ts | 37 + .../test/totp-register/totp-register.test.ts | 31 + themes/black/client/tsconfig.json | 24 + themes/black/client/tslint.json | 60 + themes/black/server/.directory | 4 + themes/black/server/src/index.ts | 28 + themes/black/server/src/lib/.directory | 4 + .../src/lib/AuthenticationSessionHandler.ts | 45 + themes/black/server/src/lib/ErrorReplies.ts | 49 + themes/black/server/src/lib/Exceptions.ts | 88 + .../server/src/lib/FirstFactorValidator.ts | 20 + .../src/lib/IdentityCheckMiddleware.spec.ts | 176 + .../server/src/lib/IdentityCheckMiddleware.ts | 138 + .../lib/IdentityCheckPreValidationTemplate.ts | 3 + .../black/server/src/lib/IdentityValidable.ts | 19 + .../src/lib/IdentityValidableStub.spec.ts | 52 + themes/black/server/src/lib/Server.spec.ts | 81 + themes/black/server/src/lib/Server.ts | 93 + .../black/server/src/lib/ServerVariables.ts | 21 + .../src/lib/ServerVariablesInitializer.ts | 116 + .../lib/ServerVariablesMockBuilder.spec.ts | 87 + .../server/src/lib/authentication/Level.ts | 5 + .../backends/GroupsAndEmails.ts | 5 + .../authentication/backends/IUsersDatabase.ts | 10 + .../backends/IUsersDatabaseStub.spec.ts | 35 + .../backends/file/FileUsersDatabase.spec.ts | 224 + .../backends/file/FileUsersDatabase.ts | 182 + .../backends/file/ReadWriteQueue.ts | 60 + .../authentication/backends/ldap/ISession.ts | 12 + .../backends/ldap/ISessionFactory.ts | 6 + .../backends/ldap/LdapUsersDatabase.spec.ts | 386 ++ .../backends/ldap/LdapUsersDatabase.ts | 107 + .../backends/ldap/SafeSession.spec.ts | 76 + .../backends/ldap/SafeSession.ts | 62 + .../backends/ldap/Sanitizer.spec.ts | 25 + .../authentication/backends/ldap/Sanitizer.ts | 25 + .../backends/ldap/Session.spec.ts | 127 + .../authentication/backends/ldap/Session.ts | 156 + .../backends/ldap/SessionFactory.ts | 37 + .../backends/ldap/SessionFactoryStub.spec.ts | 16 + .../backends/ldap/SessionStub.spec.ts | 46 + .../backends/ldap/connector/Connector.ts | 69 + .../ldap/connector/ConnectorFactory.ts | 18 + .../connector/ConnectorFactoryStub.spec.ts | 17 + .../ldap/connector/ConnectorStub.spec.ts | 34 + .../backends/ldap/connector/IConnector.ts | 9 + .../ldap/connector/IConnectorFactory.ts | 5 + .../lib/authentication/totp/ITotpHandler.ts | 6 + .../authentication/totp/TotpHandler.spec.ts | 39 + .../lib/authentication/totp/TotpHandler.ts | 36 + .../totp/TotpHandlerStub.spec.ts | 22 + .../src/lib/authentication/u2f/IU2fHandler.ts | 9 + .../src/lib/authentication/u2f/U2fHandler.ts | 24 + .../authentication/u2f/U2fHandlerStub.spec.ts | 31 + .../src/lib/authorization/Authorizer.spec.ts | 372 ++ .../src/lib/authorization/Authorizer.ts | 85 + .../lib/authorization/AuthorizerStub.spec.ts | 17 + .../src/lib/authorization/IAuthorizer.ts | 7 + .../server/src/lib/authorization/Level.ts | 6 + .../authorization/MultipleDomainMatcher.ts | 12 + .../server/src/lib/authorization/Object.ts | 5 + .../server/src/lib/authorization/Subject.ts | 5 + .../configuration/ConfigurationParser.spec.ts | 171 + .../lib/configuration/ConfigurationParser.ts | 39 + .../SessionConfigurationBuilder.spec.ts | 149 + .../SessionConfigurationBuilder.ts | 52 + .../schema/AclConfiguration.spec.ts | 34 + .../configuration/schema/AclConfiguration.ts | 41 + ...AuthenticationBackendConfiguration.spec.ts | 11 + .../AuthenticationBackendConfiguration.ts | 25 + .../lib/configuration/schema/Configuration.ts | 68 + .../schema/FileUsersDatabaseConfiguration.ts | 4 + .../schema/LdapConfiguration.spec.ts | 25 + .../configuration/schema/LdapConfiguration.ts | 40 + .../schema/NotifierConfiguration.spec.ts | 40 + .../schema/NotifierConfiguration.ts | 45 + .../schema/RegulationConfiguration.spec.ts | 13 + .../schema/RegulationConfiguration.ts | 23 + .../schema/SessionConfiguration.spec.ts | 16 + .../schema/SessionConfiguration.ts | 32 + .../schema/StorageConfiguration.spec.ts | 15 + .../schema/StorageConfiguration.ts | 30 + .../configuration/schema/TotpConfiguration.ts | 13 + .../schema/UserDatabaseConfiguration.ts | 9 + .../lib/connectors/mongo/IMongoClient.d.ts | 6 + .../lib/connectors/mongo/MongoClient.spec.ts | 119 + .../src/lib/connectors/mongo/MongoClient.ts | 76 + .../connectors/mongo/MongoClientStub.spec.ts | 16 + .../server/src/lib/logging/GlobalLogger.ts | 34 + .../src/lib/logging/GlobalLoggerStub.spec.ts | 38 + .../server/src/lib/logging/IGlobalLogger.ts | 5 + .../server/src/lib/logging/IRequestLogger.ts | 7 + .../server/src/lib/logging/RequestLogger.ts | 45 + .../src/lib/logging/RequestLoggerStub.spec.ts | 38 + .../lib/notifiers/AbstractEmailNotifier.ts | 23 + .../src/lib/notifiers/EmailNotifier.spec.ts | 54 + .../server/src/lib/notifiers/EmailNotifier.ts | 27 + .../src/lib/notifiers/FileSystemNotifier.ts | 22 + .../server/src/lib/notifiers/IMailSender.ts | 6 + .../src/lib/notifiers/IMailSenderBuilder.ts | 7 + .../server/src/lib/notifiers/INotifier.ts | 5 + .../server/src/lib/notifiers/MailSender.ts | 42 + .../lib/notifiers/MailSenderBuilder.spec.ts | 67 + .../src/lib/notifiers/MailSenderBuilder.ts | 42 + .../notifiers/MailSenderBuilderStub.spec.ts | 25 + .../src/lib/notifiers/MailSenderStub.spec.ts | 16 + .../src/lib/notifiers/NotifierFactory.spec.ts | 42 + .../src/lib/notifiers/NotifierFactory.ts | 33 + .../src/lib/notifiers/NotifierStub.spec.ts | 16 + .../server/src/lib/notifiers/SmtpNotifier.ts | 30 + .../server/src/lib/regulation/IRegulator.ts | 6 + .../src/lib/regulation/Regulator.spec.ts | 186 + .../server/src/lib/regulation/Regulator.ts | 55 + .../src/lib/regulation/RegulatorStub.spec.ts | 22 + .../src/lib/routes/error/401/get.spec.ts | 61 + .../server/src/lib/routes/error/401/get.ts | 15 + .../src/lib/routes/error/403/get.spec.ts | 61 + .../server/src/lib/routes/error/403/get.ts | 15 + .../src/lib/routes/error/404/get.spec.ts | 19 + .../server/src/lib/routes/error/404/get.ts | 8 + .../server/src/lib/routes/error/redirector.ts | 13 + .../server/src/lib/routes/firstfactor/get.ts | 72 + .../src/lib/routes/firstfactor/post.spec.ts | 136 + .../server/src/lib/routes/firstfactor/post.ts | 101 + .../server/src/lib/routes/loggedin/get.ts | 23 + .../black/server/src/lib/routes/logout/get.ts | 20 + .../lib/routes/password-reset/constants.ts | 2 + .../routes/password-reset/form/post.spec.ts | 122 + .../lib/routes/password-reset/form/post.ts | 50 + .../identity/PasswordResetHandler.spec.ts | 92 + .../identity/PasswordResetHandler.ts | 69 + .../lib/routes/password-reset/request/get.ts | 13 + .../src/lib/routes/secondfactor/get.spec.ts | 44 + .../server/src/lib/routes/secondfactor/get.ts | 28 + .../lib/routes/secondfactor/redirect.spec.ts | 41 + .../src/lib/routes/secondfactor/redirect.ts | 30 + .../lib/routes/secondfactor/totp/constants.ts | 4 + .../totp/identity/RegistrationHandler.spec.ts | 116 + .../totp/identity/RegistrationHandler.ts | 112 + .../secondfactor/totp/sign/post.spec.ts | 76 + .../lib/routes/secondfactor/totp/sign/post.ts | 42 + .../lib/routes/secondfactor/u2f/U2FCommon.ts | 11 + .../u2f/identity/RegistrationHandler.spec.ts | 96 + .../u2f/identity/RegistrationHandler.ts | 73 + .../secondfactor/u2f/register/post.spec.ts | 146 + .../routes/secondfactor/u2f/register/post.ts | 64 + .../u2f/register_request/get.spec.ts | 86 + .../secondfactor/u2f/register_request/get.ts | 43 + .../routes/secondfactor/u2f/sign/post.spec.ts | 101 + .../lib/routes/secondfactor/u2f/sign/post.ts | 57 + .../secondfactor/u2f/sign_request/get.spec.ts | 68 + .../secondfactor/u2f/sign_request/get.ts | 42 + .../src/lib/routes/verify/access_control.ts | 51 + .../server/src/lib/routes/verify/get.spec.ts | 320 + .../black/server/src/lib/routes/verify/get.ts | 91 + .../src/lib/routes/verify/get_basic_auth.ts | 55 + .../lib/routes/verify/get_session_cookie.ts | 78 + .../storage/AuthenticationTraceDocument.d.ts | 6 + .../lib/storage/CollectionFactoryFactory.ts | 15 + .../lib/storage/CollectionFactoryStub.spec.ts | 16 + .../src/lib/storage/CollectionStub.spec.ts | 39 + .../server/src/lib/storage/ICollection.d.ts | 11 + .../src/lib/storage/ICollectionFactory.d.ts | 6 + .../src/lib/storage/IUserDataStore.d.ts | 21 + .../storage/IdentityValidationDocument.d.ts | 7 + .../src/lib/storage/TOTPSecretDocument.d.ts | 6 + .../lib/storage/U2FRegistrationDocument.d.ts | 8 + .../src/lib/storage/UserDataStore.spec.ts | 264 + .../server/src/lib/storage/UserDataStore.ts | 143 + .../src/lib/storage/UserDataStoreStub.spec.ts | 64 + .../lib/storage/mongo/MongoCollection.spec.ts | 110 + .../src/lib/storage/mongo/MongoCollection.ts | 50 + .../mongo/MongoCollectionFactory.spec.ts | 21 + .../storage/mongo/MongoCollectionFactory.ts | 19 + .../lib/storage/nedb/NedbCollection.spec.ts | 136 + .../src/lib/storage/nedb/NedbCollection.ts | 47 + .../nedb/NedbCollectionFactory.spec.ts | 16 + .../lib/storage/nedb/NedbCollectionFactory.ts | 28 + .../server/src/lib/stubs/express.spec.ts | 103 + .../black/server/src/lib/stubs/ldapjs.spec.ts | 50 + .../server/src/lib/stubs/speakeasy.spec.ts | 7 + themes/black/server/src/lib/stubs/u2f.spec.ts | 16 + .../src/lib/utils/HashGenerator.spec.ts | 18 + .../server/src/lib/utils/HashGenerator.ts | 23 + .../server/src/lib/utils/ObjectCloner.ts | 6 + .../src/lib/utils/SafeRedirection.spec.ts | 33 + .../server/src/lib/utils/SafeRedirection.ts | 22 + .../src/lib/utils/URLDecomposer.spec.ts | 46 + .../server/src/lib/utils/URLDecomposer.ts | 15 + .../server/src/lib/web_server/Configurator.ts | 47 + .../server/src/lib/web_server/RestApi.ts | 125 + .../RequireValidatedFirstFactor.ts | 27 + .../middlewares/WithHeadersLogged.ts | 12 + .../server/src/resources/email-template.ejs | 254 + .../server/src/views/already-logged-in.pug | 14 + .../black/server/src/views/errors/.directory | 4 + themes/black/server/src/views/errors/401.pug | 16 + themes/black/server/src/views/errors/403.pug | 16 + themes/black/server/src/views/errors/404.pug | 11 + themes/black/server/src/views/firstfactor.pug | 23 + .../black/server/src/views/layout/layout.pug | 28 + .../src/views/need-identity-validation.pug | 12 + .../server/src/views/password-reset-form.pug | 18 + .../src/views/password-reset-request.pug | 18 + .../black/server/src/views/secondfactor.pug | 31 + .../black/server/src/views/totp-register.pug | 25 + .../black/server/src/views/u2f-register.pug | 12 + themes/black/server/test/requests.ts | 94 + themes/black/server/tsconfig.json | 21 + themes/black/server/tslint.json | 60 + themes/black/server/types/.directory | 4 + .../server/types/AuthenticationSession.ts | 18 + themes/black/server/types/Dependencies.ts | 29 + themes/black/server/types/Identity.ts | 6 + themes/black/server/types/TOTPSecret.ts | 11 + themes/black/server/types/U2FRegistration.ts | 5 + themes/black/server/types/dovehash.d.ts | 4 + themes/black/server/types/speakeasy.d.ts | 96 + .../main/server/src/views/layout/layout.pug | 1 - 274 files changed, 18976 insertions(+), 2 deletions(-) create mode 100644 themes/black/client/src/css/.directory create mode 100644 themes/black/client/src/css/00-bootstrap.min.css create mode 100644 themes/black/client/src/css/01-main.css create mode 100644 themes/black/client/src/css/02-login.css create mode 100644 themes/black/client/src/css/03-errors.css create mode 100644 themes/black/client/src/css/03-password-reset-form.css create mode 100644 themes/black/client/src/css/03-password-reset-request.css create mode 100644 themes/black/client/src/css/03-totp-register.css create mode 100644 themes/black/client/src/css/03-u2f-register.css create mode 100644 themes/black/client/src/img/RandomizedPattern.svg create mode 100644 themes/black/client/src/img/background.jpg create mode 100644 themes/black/client/src/img/icon.png create mode 100644 themes/black/client/src/img/mail.png create mode 100644 themes/black/client/src/img/notifications/.directory create mode 100644 themes/black/client/src/img/notifications/error.png create mode 100644 themes/black/client/src/img/notifications/info.png create mode 100644 themes/black/client/src/img/notifications/success.png create mode 100644 themes/black/client/src/img/notifications/warning.png create mode 100644 themes/black/client/src/img/padlock.png create mode 100644 themes/black/client/src/img/password_white.png create mode 100644 themes/black/client/src/img/pendrive.png create mode 100644 themes/black/client/src/img/sharingan.png create mode 100644 themes/black/client/src/img/stores/.directory create mode 100644 themes/black/client/src/img/stores/applestore-badge.svg create mode 100644 themes/black/client/src/img/stores/googleplay-badge.svg create mode 100644 themes/black/client/src/img/success.png create mode 100644 themes/black/client/src/img/user.png create mode 100644 themes/black/client/src/img/warning.png create mode 100644 themes/black/client/src/index.ts create mode 100644 themes/black/client/src/lib/GetPromised.ts create mode 100644 themes/black/client/src/lib/INotifier.ts create mode 100644 themes/black/client/src/lib/Notifier.ts create mode 100644 themes/black/client/src/lib/QueryParametersRetriever.ts create mode 100644 themes/black/client/src/lib/SafeRedirect.ts create mode 100644 themes/black/client/src/lib/firstfactor/FirstFactorValidator.ts create mode 100644 themes/black/client/src/lib/firstfactor/UISelectors.ts create mode 100644 themes/black/client/src/lib/firstfactor/index.ts create mode 100644 themes/black/client/src/lib/reset-password/constants.ts create mode 100644 themes/black/client/src/lib/reset-password/reset-password-form.ts create mode 100644 themes/black/client/src/lib/reset-password/reset-password-request.ts create mode 100644 themes/black/client/src/lib/secondfactor/TOTPValidator.ts create mode 100644 themes/black/client/src/lib/secondfactor/U2FValidator.ts create mode 100644 themes/black/client/src/lib/secondfactor/constants.ts create mode 100644 themes/black/client/src/lib/secondfactor/index.ts create mode 100644 themes/black/client/src/lib/totp-register/totp-register.ts create mode 100644 themes/black/client/src/lib/totp-register/ui-selector.ts create mode 100644 themes/black/client/src/lib/u2f-register/u2f-register.ts create mode 100644 themes/black/client/src/thirdparties/qrcode.min.js create mode 100644 themes/black/client/src/thirdparties/u2f-api.js create mode 100644 themes/black/client/test/Notifier.test.ts create mode 100644 themes/black/client/test/firstfactor/FirstFactorValidator.test.ts create mode 100644 themes/black/client/test/mocks/NotifierStub.ts create mode 100644 themes/black/client/test/mocks/jquery.ts create mode 100644 themes/black/client/test/mocks/u2f-api.ts create mode 100644 themes/black/client/test/secondfactor/TOTPValidator.test.ts create mode 100644 themes/black/client/test/totp-register/totp-register.test.ts create mode 100644 themes/black/client/tsconfig.json create mode 100644 themes/black/client/tslint.json create mode 100644 themes/black/server/.directory create mode 100755 themes/black/server/src/index.ts create mode 100644 themes/black/server/src/lib/.directory create mode 100644 themes/black/server/src/lib/AuthenticationSessionHandler.ts create mode 100644 themes/black/server/src/lib/ErrorReplies.ts create mode 100644 themes/black/server/src/lib/Exceptions.ts create mode 100644 themes/black/server/src/lib/FirstFactorValidator.ts create mode 100644 themes/black/server/src/lib/IdentityCheckMiddleware.spec.ts create mode 100644 themes/black/server/src/lib/IdentityCheckMiddleware.ts create mode 100644 themes/black/server/src/lib/IdentityCheckPreValidationTemplate.ts create mode 100644 themes/black/server/src/lib/IdentityValidable.ts create mode 100644 themes/black/server/src/lib/IdentityValidableStub.spec.ts create mode 100644 themes/black/server/src/lib/Server.spec.ts create mode 100644 themes/black/server/src/lib/Server.ts create mode 100644 themes/black/server/src/lib/ServerVariables.ts create mode 100644 themes/black/server/src/lib/ServerVariablesInitializer.ts create mode 100644 themes/black/server/src/lib/ServerVariablesMockBuilder.spec.ts create mode 100644 themes/black/server/src/lib/authentication/Level.ts create mode 100644 themes/black/server/src/lib/authentication/backends/GroupsAndEmails.ts create mode 100644 themes/black/server/src/lib/authentication/backends/IUsersDatabase.ts create mode 100644 themes/black/server/src/lib/authentication/backends/IUsersDatabaseStub.spec.ts create mode 100644 themes/black/server/src/lib/authentication/backends/file/FileUsersDatabase.spec.ts create mode 100644 themes/black/server/src/lib/authentication/backends/file/FileUsersDatabase.ts create mode 100644 themes/black/server/src/lib/authentication/backends/file/ReadWriteQueue.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/ISession.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/ISessionFactory.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.spec.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/SafeSession.spec.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/SafeSession.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/Sanitizer.spec.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/Sanitizer.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/Session.spec.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/Session.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/SessionFactory.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/SessionFactoryStub.spec.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/SessionStub.spec.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/connector/Connector.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/connector/ConnectorFactory.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/connector/ConnectorFactoryStub.spec.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/connector/ConnectorStub.spec.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/connector/IConnector.ts create mode 100644 themes/black/server/src/lib/authentication/backends/ldap/connector/IConnectorFactory.ts create mode 100644 themes/black/server/src/lib/authentication/totp/ITotpHandler.ts create mode 100644 themes/black/server/src/lib/authentication/totp/TotpHandler.spec.ts create mode 100644 themes/black/server/src/lib/authentication/totp/TotpHandler.ts create mode 100644 themes/black/server/src/lib/authentication/totp/TotpHandlerStub.spec.ts create mode 100644 themes/black/server/src/lib/authentication/u2f/IU2fHandler.ts create mode 100644 themes/black/server/src/lib/authentication/u2f/U2fHandler.ts create mode 100644 themes/black/server/src/lib/authentication/u2f/U2fHandlerStub.spec.ts create mode 100644 themes/black/server/src/lib/authorization/Authorizer.spec.ts create mode 100644 themes/black/server/src/lib/authorization/Authorizer.ts create mode 100644 themes/black/server/src/lib/authorization/AuthorizerStub.spec.ts create mode 100644 themes/black/server/src/lib/authorization/IAuthorizer.ts create mode 100644 themes/black/server/src/lib/authorization/Level.ts create mode 100644 themes/black/server/src/lib/authorization/MultipleDomainMatcher.ts create mode 100644 themes/black/server/src/lib/authorization/Object.ts create mode 100644 themes/black/server/src/lib/authorization/Subject.ts create mode 100644 themes/black/server/src/lib/configuration/ConfigurationParser.spec.ts create mode 100644 themes/black/server/src/lib/configuration/ConfigurationParser.ts create mode 100644 themes/black/server/src/lib/configuration/SessionConfigurationBuilder.spec.ts create mode 100644 themes/black/server/src/lib/configuration/SessionConfigurationBuilder.ts create mode 100644 themes/black/server/src/lib/configuration/schema/AclConfiguration.spec.ts create mode 100644 themes/black/server/src/lib/configuration/schema/AclConfiguration.ts create mode 100644 themes/black/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.spec.ts create mode 100644 themes/black/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.ts create mode 100644 themes/black/server/src/lib/configuration/schema/Configuration.ts create mode 100644 themes/black/server/src/lib/configuration/schema/FileUsersDatabaseConfiguration.ts create mode 100644 themes/black/server/src/lib/configuration/schema/LdapConfiguration.spec.ts create mode 100644 themes/black/server/src/lib/configuration/schema/LdapConfiguration.ts create mode 100644 themes/black/server/src/lib/configuration/schema/NotifierConfiguration.spec.ts create mode 100644 themes/black/server/src/lib/configuration/schema/NotifierConfiguration.ts create mode 100644 themes/black/server/src/lib/configuration/schema/RegulationConfiguration.spec.ts create mode 100644 themes/black/server/src/lib/configuration/schema/RegulationConfiguration.ts create mode 100644 themes/black/server/src/lib/configuration/schema/SessionConfiguration.spec.ts create mode 100644 themes/black/server/src/lib/configuration/schema/SessionConfiguration.ts create mode 100644 themes/black/server/src/lib/configuration/schema/StorageConfiguration.spec.ts create mode 100644 themes/black/server/src/lib/configuration/schema/StorageConfiguration.ts create mode 100644 themes/black/server/src/lib/configuration/schema/TotpConfiguration.ts create mode 100644 themes/black/server/src/lib/configuration/schema/UserDatabaseConfiguration.ts create mode 100644 themes/black/server/src/lib/connectors/mongo/IMongoClient.d.ts create mode 100644 themes/black/server/src/lib/connectors/mongo/MongoClient.spec.ts create mode 100644 themes/black/server/src/lib/connectors/mongo/MongoClient.ts create mode 100644 themes/black/server/src/lib/connectors/mongo/MongoClientStub.spec.ts create mode 100644 themes/black/server/src/lib/logging/GlobalLogger.ts create mode 100644 themes/black/server/src/lib/logging/GlobalLoggerStub.spec.ts create mode 100644 themes/black/server/src/lib/logging/IGlobalLogger.ts create mode 100644 themes/black/server/src/lib/logging/IRequestLogger.ts create mode 100644 themes/black/server/src/lib/logging/RequestLogger.ts create mode 100644 themes/black/server/src/lib/logging/RequestLoggerStub.spec.ts create mode 100644 themes/black/server/src/lib/notifiers/AbstractEmailNotifier.ts create mode 100644 themes/black/server/src/lib/notifiers/EmailNotifier.spec.ts create mode 100644 themes/black/server/src/lib/notifiers/EmailNotifier.ts create mode 100644 themes/black/server/src/lib/notifiers/FileSystemNotifier.ts create mode 100644 themes/black/server/src/lib/notifiers/IMailSender.ts create mode 100644 themes/black/server/src/lib/notifiers/IMailSenderBuilder.ts create mode 100644 themes/black/server/src/lib/notifiers/INotifier.ts create mode 100644 themes/black/server/src/lib/notifiers/MailSender.ts create mode 100644 themes/black/server/src/lib/notifiers/MailSenderBuilder.spec.ts create mode 100644 themes/black/server/src/lib/notifiers/MailSenderBuilder.ts create mode 100644 themes/black/server/src/lib/notifiers/MailSenderBuilderStub.spec.ts create mode 100644 themes/black/server/src/lib/notifiers/MailSenderStub.spec.ts create mode 100644 themes/black/server/src/lib/notifiers/NotifierFactory.spec.ts create mode 100644 themes/black/server/src/lib/notifiers/NotifierFactory.ts create mode 100644 themes/black/server/src/lib/notifiers/NotifierStub.spec.ts create mode 100644 themes/black/server/src/lib/notifiers/SmtpNotifier.ts create mode 100644 themes/black/server/src/lib/regulation/IRegulator.ts create mode 100644 themes/black/server/src/lib/regulation/Regulator.spec.ts create mode 100644 themes/black/server/src/lib/regulation/Regulator.ts create mode 100644 themes/black/server/src/lib/regulation/RegulatorStub.spec.ts create mode 100644 themes/black/server/src/lib/routes/error/401/get.spec.ts create mode 100644 themes/black/server/src/lib/routes/error/401/get.ts create mode 100644 themes/black/server/src/lib/routes/error/403/get.spec.ts create mode 100644 themes/black/server/src/lib/routes/error/403/get.ts create mode 100644 themes/black/server/src/lib/routes/error/404/get.spec.ts create mode 100644 themes/black/server/src/lib/routes/error/404/get.ts create mode 100644 themes/black/server/src/lib/routes/error/redirector.ts create mode 100644 themes/black/server/src/lib/routes/firstfactor/get.ts create mode 100644 themes/black/server/src/lib/routes/firstfactor/post.spec.ts create mode 100644 themes/black/server/src/lib/routes/firstfactor/post.ts create mode 100644 themes/black/server/src/lib/routes/loggedin/get.ts create mode 100644 themes/black/server/src/lib/routes/logout/get.ts create mode 100644 themes/black/server/src/lib/routes/password-reset/constants.ts create mode 100644 themes/black/server/src/lib/routes/password-reset/form/post.spec.ts create mode 100644 themes/black/server/src/lib/routes/password-reset/form/post.ts create mode 100644 themes/black/server/src/lib/routes/password-reset/identity/PasswordResetHandler.spec.ts create mode 100644 themes/black/server/src/lib/routes/password-reset/identity/PasswordResetHandler.ts create mode 100644 themes/black/server/src/lib/routes/password-reset/request/get.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/get.spec.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/get.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/redirect.spec.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/redirect.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/totp/constants.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.spec.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/totp/sign/post.spec.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/totp/sign/post.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/u2f/U2FCommon.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.spec.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/u2f/register/post.spec.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/u2f/register/post.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/u2f/register_request/get.spec.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/u2f/register_request/get.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/u2f/sign/post.spec.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/u2f/sign/post.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/u2f/sign_request/get.spec.ts create mode 100644 themes/black/server/src/lib/routes/secondfactor/u2f/sign_request/get.ts create mode 100644 themes/black/server/src/lib/routes/verify/access_control.ts create mode 100644 themes/black/server/src/lib/routes/verify/get.spec.ts create mode 100644 themes/black/server/src/lib/routes/verify/get.ts create mode 100644 themes/black/server/src/lib/routes/verify/get_basic_auth.ts create mode 100644 themes/black/server/src/lib/routes/verify/get_session_cookie.ts create mode 100644 themes/black/server/src/lib/storage/AuthenticationTraceDocument.d.ts create mode 100644 themes/black/server/src/lib/storage/CollectionFactoryFactory.ts create mode 100644 themes/black/server/src/lib/storage/CollectionFactoryStub.spec.ts create mode 100644 themes/black/server/src/lib/storage/CollectionStub.spec.ts create mode 100644 themes/black/server/src/lib/storage/ICollection.d.ts create mode 100644 themes/black/server/src/lib/storage/ICollectionFactory.d.ts create mode 100644 themes/black/server/src/lib/storage/IUserDataStore.d.ts create mode 100644 themes/black/server/src/lib/storage/IdentityValidationDocument.d.ts create mode 100644 themes/black/server/src/lib/storage/TOTPSecretDocument.d.ts create mode 100644 themes/black/server/src/lib/storage/U2FRegistrationDocument.d.ts create mode 100644 themes/black/server/src/lib/storage/UserDataStore.spec.ts create mode 100644 themes/black/server/src/lib/storage/UserDataStore.ts create mode 100644 themes/black/server/src/lib/storage/UserDataStoreStub.spec.ts create mode 100644 themes/black/server/src/lib/storage/mongo/MongoCollection.spec.ts create mode 100644 themes/black/server/src/lib/storage/mongo/MongoCollection.ts create mode 100644 themes/black/server/src/lib/storage/mongo/MongoCollectionFactory.spec.ts create mode 100644 themes/black/server/src/lib/storage/mongo/MongoCollectionFactory.ts create mode 100644 themes/black/server/src/lib/storage/nedb/NedbCollection.spec.ts create mode 100644 themes/black/server/src/lib/storage/nedb/NedbCollection.ts create mode 100644 themes/black/server/src/lib/storage/nedb/NedbCollectionFactory.spec.ts create mode 100644 themes/black/server/src/lib/storage/nedb/NedbCollectionFactory.ts create mode 100644 themes/black/server/src/lib/stubs/express.spec.ts create mode 100644 themes/black/server/src/lib/stubs/ldapjs.spec.ts create mode 100644 themes/black/server/src/lib/stubs/speakeasy.spec.ts create mode 100644 themes/black/server/src/lib/stubs/u2f.spec.ts create mode 100644 themes/black/server/src/lib/utils/HashGenerator.spec.ts create mode 100644 themes/black/server/src/lib/utils/HashGenerator.ts create mode 100644 themes/black/server/src/lib/utils/ObjectCloner.ts create mode 100644 themes/black/server/src/lib/utils/SafeRedirection.spec.ts create mode 100644 themes/black/server/src/lib/utils/SafeRedirection.ts create mode 100644 themes/black/server/src/lib/utils/URLDecomposer.spec.ts create mode 100644 themes/black/server/src/lib/utils/URLDecomposer.ts create mode 100644 themes/black/server/src/lib/web_server/Configurator.ts create mode 100644 themes/black/server/src/lib/web_server/RestApi.ts create mode 100644 themes/black/server/src/lib/web_server/middlewares/RequireValidatedFirstFactor.ts create mode 100644 themes/black/server/src/lib/web_server/middlewares/WithHeadersLogged.ts create mode 100644 themes/black/server/src/resources/email-template.ejs create mode 100644 themes/black/server/src/views/already-logged-in.pug create mode 100644 themes/black/server/src/views/errors/.directory create mode 100644 themes/black/server/src/views/errors/401.pug create mode 100644 themes/black/server/src/views/errors/403.pug create mode 100644 themes/black/server/src/views/errors/404.pug create mode 100644 themes/black/server/src/views/firstfactor.pug create mode 100644 themes/black/server/src/views/layout/layout.pug create mode 100644 themes/black/server/src/views/need-identity-validation.pug create mode 100644 themes/black/server/src/views/password-reset-form.pug create mode 100644 themes/black/server/src/views/password-reset-request.pug create mode 100644 themes/black/server/src/views/secondfactor.pug create mode 100644 themes/black/server/src/views/totp-register.pug create mode 100644 themes/black/server/src/views/u2f-register.pug create mode 100644 themes/black/server/test/requests.ts create mode 100644 themes/black/server/tsconfig.json create mode 100644 themes/black/server/tslint.json create mode 100644 themes/black/server/types/.directory create mode 100644 themes/black/server/types/AuthenticationSession.ts create mode 100644 themes/black/server/types/Dependencies.ts create mode 100644 themes/black/server/types/Identity.ts create mode 100644 themes/black/server/types/TOTPSecret.ts create mode 100644 themes/black/server/types/U2FRegistration.ts create mode 100644 themes/black/server/types/dovehash.d.ts create mode 100644 themes/black/server/types/speakeasy.d.ts diff --git a/Gruntfile.js b/Gruntfile.js index e5ae1919..1509cc90 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -131,6 +131,30 @@ module.exports = function (grunt) { src: '**', dest: `${buildDir}/server/src/public_html/js/` }, + black_resources: { + expand: true, + cwd: 'themes/black/server/src/resources', + src: '**', + dest: `${buildDir}/server/src/resources/` + }, + black_views: { + expand: true, + cwd: 'themes/black/server/src/views', + src: '**', + dest: `${buildDir}/server/src/views/` + }, + black_images: { + expand: true, + cwd: 'themes/black/client/src/img', + src: '**', + dest: `${buildDir}/server/src/public_html/img/` + }, + black_thirdparties: { + expand: true, + cwd: 'themes/black/client/src/thirdparties', + src: '**', + dest: `${buildDir}/server/src/public_html/js/` + }, schema: { src: schemaDir, dest: `${buildDir}/${schemaDir}` @@ -206,6 +230,10 @@ module.exports = function (grunt) { src: ['themes/matrix/client/src/css/*.css'], dest: `${buildDir}/server/src/public_html/css/authelia.css` }, + black_css: { + src: ['themes/black/client/src/css/*.css'], + dest: `${buildDir}/server/src/public_html/css/authelia.css` + }, }, cssmin: { target: { @@ -243,10 +271,13 @@ module.exports = function (grunt) { grunt.registerTask('copy-resources-matrix', ['copy:matrix_resources', 'copy:matrix_views', 'copy:matrix_images', 'copy:matrix_thirdparties', 'concat:matrix_css']); + grunt.registerTask('copy-resources-black', ['copy:black_resources', 'copy:black_views', 'copy:black_images', 'copy:black_thirdparties', 'concat:black_css']); + grunt.registerTask('build-client', ['compile-client', 'browserify']); grunt.registerTask('build-server-main', ['compile-server', 'copy-resources-main', 'generate-config-schema']); grunt.registerTask('build-server-matrix', ['compile-server', 'copy-resources-matrix', 'generate-config-schema']); - + grunt.registerTask('build-server-black', ['compile-server', 'copy-resources-black', 'generate-config-schema']); + grunt.registerTask('build', ['build-client', 'build-server-'+target]); grunt.registerTask('build-dist', ['clean', 'build', 'run:minify', 'cssmin', 'run:include-minified-script']); diff --git a/themes/black/client/src/css/.directory b/themes/black/client/src/css/.directory new file mode 100644 index 00000000..6e4b3f63 --- /dev/null +++ b/themes/black/client/src/css/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,17,20,56,41 +Version=3 +ViewMode=1 diff --git a/themes/black/client/src/css/00-bootstrap.min.css b/themes/black/client/src/css/00-bootstrap.min.css new file mode 100644 index 00000000..dfeacbb8 --- /dev/null +++ b/themes/black/client/src/css/00-bootstrap.min.css @@ -0,0 +1,5768 @@ +/*! * Bootstrap v3.3.7 (http://getbootstrap.com) * Copyright 2011-2016 Twitter, Inc. * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) */ +/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */ +html{ + font-family:sans-serif; + -webkit-text-size-adjust:100%; + -ms-text-size-adjust:100% +} +body{ + margin:0 +} +article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{ + display:block +} +audio,canvas,progress,video{ + display:inline-block; + vertical-align:baseline +} +audio:not([controls]){ + display:none; + height:0 +} +[hidden],template{ + display:none +} +a{ + background-color:transparent +} +a:active,a:hover{ + outline:0 +} +abbr[title]{ + border-bottom:1px dotted +} +b,strong{ + font-weight:700 +} +dfn{ + font-style:italic +} +h1{ + margin:.67em 0; + font-size:2em +} +mark{ + color:#000; + background:#ff0 +} +small{ + font-size:80% +} +sub,sup{ + position:relative; + font-size:75%; + line-height:0; + vertical-align:baseline +} +sup{ + top:-.5em +} +sub{ + bottom:-.25em +} +img{ + border:0 +} +svg:not(:root){ + overflow:hidden +} +figure{ + margin:1em 40px +} +hr{ + height:0; + -webkit-box-sizing:content-box; + -moz-box-sizing:content-box; + box-sizing:content-box +} +pre{ + overflow:auto +} +code,kbd,pre,samp{ + font-family:monospace,monospace; + font-size:1em +} +button,input,optgroup,select,textarea{ + margin:0; + font:inherit; + color:inherit +} +button{ + overflow:visible +} +button,select{ + text-transform:none +} +button,html input[type=button],input[type=reset],input[type=submit]{ + -webkit-appearance:button; + cursor:pointer +} +button[disabled],html input[disabled]{ + cursor:default +} +button::-moz-focus-inner,input::-moz-focus-inner{ + padding:0; + border:0 +} +input{ + line-height:normal +} +input[type=checkbox],input[type=radio]{ + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + padding:0 +} +input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{ + height:auto +} +input[type=search]{ + -webkit-box-sizing:content-box; + -moz-box-sizing:content-box; + box-sizing:content-box; + -webkit-appearance:textfield +} +input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{ + -webkit-appearance:none +} +fieldset{ + padding:.35em .625em .75em; + margin:0 2px; + border:1px solid silver +} +legend{ + padding:0; + border:0 +} +textarea{ + overflow:auto +} +optgroup{ + font-weight:700 +} +table{ + border-spacing:0; + border-collapse:collapse +} +td,th{ + padding:0 +} +/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */ +@media print{ + *,:after,:before{ + color:#000!important; + text-shadow:none!important; + background:0 0!important; + -webkit-box-shadow:none!important; + box-shadow:none!important + } + a,a:visited{ + text-decoration:underline + } + a[href]:after{ + content:" (" attr(href) ")" + } + abbr[title]:after{ + content:" (" attr(title) ")" + } + a[href^="javascript:"]:after,a[href^="#"]:after{ + content:"" + } + blockquote,pre{ + border:1px solid #999; + page-break-inside:avoid + } + thead{ + display:table-header-group + } + img,tr{ + page-break-inside:avoid + } + img{ + max-width:100%!important + } + h2,h3,p{ + orphans:3; + widows:3 + } + h2,h3{ + page-break-after:avoid + } + .navbar{ + display:none + } + .btn>.caret,.dropup>.btn>.caret{ + border-top-color:#000!important + } + .label{ + border:1px solid #000 + } + .table{ + border-collapse:collapse!important + } + .table td,.table th{ + background-color:#fff!important + } + .table-bordered td,.table-bordered th{ + border:1px solid #ddd!important + } +} +@font-face{ + font-family:'Glyphicons Halflings'; + src:url(../fonts/glyphicons-halflings-regular.eot); + src:url(../fonts/glyphicons-halflings-regular.eot?#iefix) format('embedded-opentype'),url(../fonts/glyphicons-halflings-regular.woff2) format('woff2'),url(../fonts/glyphicons-halflings-regular.woff) format('woff'),url(../fonts/glyphicons-halflings-regular.ttf) format('truetype'),url(../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format('svg') +} +.glyphicon{ + position:relative; + top:1px; + display:inline-block; + font-family:'Glyphicons Halflings'; + font-style:normal; + font-weight:400; + line-height:1; + -webkit-font-smoothing:antialiased; + -moz-osx-font-smoothing:grayscale +} +.glyphicon-asterisk:before{ + content:"\002a" +} +.glyphicon-plus:before{ + content:"\002b" +} +.glyphicon-eur:before,.glyphicon-euro:before{ + content:"\20ac" +} +.glyphicon-minus:before{ + content:"\2212" +} +.glyphicon-cloud:before{ + content:"\2601" +} +.glyphicon-envelope:before{ + content:"\2709" +} +.glyphicon-pencil:before{ + content:"\270f" +} +.glyphicon-glass:before{ + content:"\e001" +} +.glyphicon-music:before{ + content:"\e002" +} +.glyphicon-search:before{ + content:"\e003" +} +.glyphicon-heart:before{ + content:"\e005" +} +.glyphicon-star:before{ + content:"\e006" +} +.glyphicon-star-empty:before{ + content:"\e007" +} +.glyphicon-user:before{ + content:"\e008" +} +.glyphicon-film:before{ + content:"\e009" +} +.glyphicon-th-large:before{ + content:"\e010" +} +.glyphicon-th:before{ + content:"\e011" +} +.glyphicon-th-list:before{ + content:"\e012" +} +.glyphicon-ok:before{ + content:"\e013" +} +.glyphicon-remove:before{ + content:"\e014" +} +.glyphicon-zoom-in:before{ + content:"\e015" +} +.glyphicon-zoom-out:before{ + content:"\e016" +} +.glyphicon-off:before{ + content:"\e017" +} +.glyphicon-signal:before{ + content:"\e018" +} +.glyphicon-cog:before{ + content:"\e019" +} +.glyphicon-trash:before{ + content:"\e020" +} +.glyphicon-home:before{ + content:"\e021" +} +.glyphicon-file:before{ + content:"\e022" +} +.glyphicon-time:before{ + content:"\e023" +} +.glyphicon-road:before{ + content:"\e024" +} +.glyphicon-download-alt:before{ + content:"\e025" +} +.glyphicon-download:before{ + content:"\e026" +} +.glyphicon-upload:before{ + content:"\e027" +} +.glyphicon-inbox:before{ + content:"\e028" +} +.glyphicon-play-circle:before{ + content:"\e029" +} +.glyphicon-repeat:before{ + content:"\e030" +} +.glyphicon-refresh:before{ + content:"\e031" +} +.glyphicon-list-alt:before{ + content:"\e032" +} +.glyphicon-lock:before{ + content:"\e033" +} +.glyphicon-flag:before{ + content:"\e034" +} +.glyphicon-headphones:before{ + content:"\e035" +} +.glyphicon-volume-off:before{ + content:"\e036" +} +.glyphicon-volume-down:before{ + content:"\e037" +} +.glyphicon-volume-up:before{ + content:"\e038" +} +.glyphicon-qrcode:before{ + content:"\e039" +} +.glyphicon-barcode:before{ + content:"\e040" +} +.glyphicon-tag:before{ + content:"\e041" +} +.glyphicon-tags:before{ + content:"\e042" +} +.glyphicon-book:before{ + content:"\e043" +} +.glyphicon-bookmark:before{ + content:"\e044" +} +.glyphicon-print:before{ + content:"\e045" +} +.glyphicon-camera:before{ + content:"\e046" +} +.glyphicon-font:before{ + content:"\e047" +} +.glyphicon-bold:before{ + content:"\e048" +} +.glyphicon-italic:before{ + content:"\e049" +} +.glyphicon-text-height:before{ + content:"\e050" +} +.glyphicon-text-width:before{ + content:"\e051" +} +.glyphicon-align-left:before{ + content:"\e052" +} +.glyphicon-align-center:before{ + content:"\e053" +} +.glyphicon-align-right:before{ + content:"\e054" +} +.glyphicon-align-justify:before{ + content:"\e055" +} +.glyphicon-list:before{ + content:"\e056" +} +.glyphicon-indent-left:before{ + content:"\e057" +} +.glyphicon-indent-right:before{ + content:"\e058" +} +.glyphicon-facetime-video:before{ + content:"\e059" +} +.glyphicon-picture:before{ + content:"\e060" +} +.glyphicon-map-marker:before{ + content:"\e062" +} +.glyphicon-adjust:before{ + content:"\e063" +} +.glyphicon-tint:before{ + content:"\e064" +} +.glyphicon-edit:before{ + content:"\e065" +} +.glyphicon-share:before{ + content:"\e066" +} +.glyphicon-check:before{ + content:"\e067" +} +.glyphicon-move:before{ + content:"\e068" +} +.glyphicon-step-backward:before{ + content:"\e069" +} +.glyphicon-fast-backward:before{ + content:"\e070" +} +.glyphicon-backward:before{ + content:"\e071" +} +.glyphicon-play:before{ + content:"\e072" +} +.glyphicon-pause:before{ + content:"\e073" +} +.glyphicon-stop:before{ + content:"\e074" +} +.glyphicon-forward:before{ + content:"\e075" +} +.glyphicon-fast-forward:before{ + content:"\e076" +} +.glyphicon-step-forward:before{ + content:"\e077" +} +.glyphicon-eject:before{ + content:"\e078" +} +.glyphicon-chevron-left:before{ + content:"\e079" +} +.glyphicon-chevron-right:before{ + content:"\e080" +} +.glyphicon-plus-sign:before{ + content:"\e081" +} +.glyphicon-minus-sign:before{ + content:"\e082" +} +.glyphicon-remove-sign:before{ + content:"\e083" +} +.glyphicon-ok-sign:before{ + content:"\e084" +} +.glyphicon-question-sign:before{ + content:"\e085" +} +.glyphicon-info-sign:before{ + content:"\e086" +} +.glyphicon-screenshot:before{ + content:"\e087" +} +.glyphicon-remove-circle:before{ + content:"\e088" +} +.glyphicon-ok-circle:before{ + content:"\e089" +} +.glyphicon-ban-circle:before{ + content:"\e090" +} +.glyphicon-arrow-left:before{ + content:"\e091" +} +.glyphicon-arrow-right:before{ + content:"\e092" +} +.glyphicon-arrow-up:before{ + content:"\e093" +} +.glyphicon-arrow-down:before{ + content:"\e094" +} +.glyphicon-share-alt:before{ + content:"\e095" +} +.glyphicon-resize-full:before{ + content:"\e096" +} +.glyphicon-resize-small:before{ + content:"\e097" +} +.glyphicon-exclamation-sign:before{ + content:"\e101" +} +.glyphicon-gift:before{ + content:"\e102" +} +.glyphicon-leaf:before{ + content:"\e103" +} +.glyphicon-fire:before{ + content:"\e104" +} +.glyphicon-eye-open:before{ + content:"\e105" +} +.glyphicon-eye-close:before{ + content:"\e106" +} +.glyphicon-warning-sign:before{ + content:"\e107" +} +.glyphicon-plane:before{ + content:"\e108" +} +.glyphicon-calendar:before{ + content:"\e109" +} +.glyphicon-random:before{ + content:"\e110" +} +.glyphicon-comment:before{ + content:"\e111" +} +.glyphicon-magnet:before{ + content:"\e112" +} +.glyphicon-chevron-up:before{ + content:"\e113" +} +.glyphicon-chevron-down:before{ + content:"\e114" +} +.glyphicon-retweet:before{ + content:"\e115" +} +.glyphicon-shopping-cart:before{ + content:"\e116" +} +.glyphicon-folder-close:before{ + content:"\e117" +} +.glyphicon-folder-open:before{ + content:"\e118" +} +.glyphicon-resize-vertical:before{ + content:"\e119" +} +.glyphicon-resize-horizontal:before{ + content:"\e120" +} +.glyphicon-hdd:before{ + content:"\e121" +} +.glyphicon-bullhorn:before{ + content:"\e122" +} +.glyphicon-bell:before{ + content:"\e123" +} +.glyphicon-certificate:before{ + content:"\e124" +} +.glyphicon-thumbs-up:before{ + content:"\e125" +} +.glyphicon-thumbs-down:before{ + content:"\e126" +} +.glyphicon-hand-right:before{ + content:"\e127" +} +.glyphicon-hand-left:before{ + content:"\e128" +} +.glyphicon-hand-up:before{ + content:"\e129" +} +.glyphicon-hand-down:before{ + content:"\e130" +} +.glyphicon-circle-arrow-right:before{ + content:"\e131" +} +.glyphicon-circle-arrow-left:before{ + content:"\e132" +} +.glyphicon-circle-arrow-up:before{ + content:"\e133" +} +.glyphicon-circle-arrow-down:before{ + content:"\e134" +} +.glyphicon-globe:before{ + content:"\e135" +} +.glyphicon-wrench:before{ + content:"\e136" +} +.glyphicon-tasks:before{ + content:"\e137" +} +.glyphicon-filter:before{ + content:"\e138" +} +.glyphicon-briefcase:before{ + content:"\e139" +} +.glyphicon-fullscreen:before{ + content:"\e140" +} +.glyphicon-dashboard:before{ + content:"\e141" +} +.glyphicon-paperclip:before{ + content:"\e142" +} +.glyphicon-heart-empty:before{ + content:"\e143" +} +.glyphicon-link:before{ + content:"\e144" +} +.glyphicon-phone:before{ + content:"\e145" +} +.glyphicon-pushpin:before{ + content:"\e146" +} +.glyphicon-usd:before{ + content:"\e148" +} +.glyphicon-gbp:before{ + content:"\e149" +} +.glyphicon-sort:before{ + content:"\e150" +} +.glyphicon-sort-by-alphabet:before{ + content:"\e151" +} +.glyphicon-sort-by-alphabet-alt:before{ + content:"\e152" +} +.glyphicon-sort-by-order:before{ + content:"\e153" +} +.glyphicon-sort-by-order-alt:before{ + content:"\e154" +} +.glyphicon-sort-by-attributes:before{ + content:"\e155" +} +.glyphicon-sort-by-attributes-alt:before{ + content:"\e156" +} +.glyphicon-unchecked:before{ + content:"\e157" +} +.glyphicon-expand:before{ + content:"\e158" +} +.glyphicon-collapse-down:before{ + content:"\e159" +} +.glyphicon-collapse-up:before{ + content:"\e160" +} +.glyphicon-log-in:before{ + content:"\e161" +} +.glyphicon-flash:before{ + content:"\e162" +} +.glyphicon-log-out:before{ + content:"\e163" +} +.glyphicon-new-window:before{ + content:"\e164" +} +.glyphicon-record:before{ + content:"\e165" +} +.glyphicon-save:before{ + content:"\e166" +} +.glyphicon-open:before{ + content:"\e167" +} +.glyphicon-saved:before{ + content:"\e168" +} +.glyphicon-import:before{ + content:"\e169" +} +.glyphicon-export:before{ + content:"\e170" +} +.glyphicon-send:before{ + content:"\e171" +} +.glyphicon-floppy-disk:before{ + content:"\e172" +} +.glyphicon-floppy-saved:before{ + content:"\e173" +} +.glyphicon-floppy-remove:before{ + content:"\e174" +} +.glyphicon-floppy-save:before{ + content:"\e175" +} +.glyphicon-floppy-open:before{ + content:"\e176" +} +.glyphicon-credit-card:before{ + content:"\e177" +} +.glyphicon-transfer:before{ + content:"\e178" +} +.glyphicon-cutlery:before{ + content:"\e179" +} +.glyphicon-header:before{ + content:"\e180" +} +.glyphicon-compressed:before{ + content:"\e181" +} +.glyphicon-earphone:before{ + content:"\e182" +} +.glyphicon-phone-alt:before{ + content:"\e183" +} +.glyphicon-tower:before{ + content:"\e184" +} +.glyphicon-stats:before{ + content:"\e185" +} +.glyphicon-sd-video:before{ + content:"\e186" +} +.glyphicon-hd-video:before{ + content:"\e187" +} +.glyphicon-subtitles:before{ + content:"\e188" +} +.glyphicon-sound-stereo:before{ + content:"\e189" +} +.glyphicon-sound-dolby:before{ + content:"\e190" +} +.glyphicon-sound-5-1:before{ + content:"\e191" +} +.glyphicon-sound-6-1:before{ + content:"\e192" +} +.glyphicon-sound-7-1:before{ + content:"\e193" +} +.glyphicon-copyright-mark:before{ + content:"\e194" +} +.glyphicon-registration-mark:before{ + content:"\e195" +} +.glyphicon-cloud-download:before{ + content:"\e197" +} +.glyphicon-cloud-upload:before{ + content:"\e198" +} +.glyphicon-tree-conifer:before{ + content:"\e199" +} +.glyphicon-tree-deciduous:before{ + content:"\e200" +} +.glyphicon-cd:before{ + content:"\e201" +} +.glyphicon-save-file:before{ + content:"\e202" +} +.glyphicon-open-file:before{ + content:"\e203" +} +.glyphicon-level-up:before{ + content:"\e204" +} +.glyphicon-copy:before{ + content:"\e205" +} +.glyphicon-paste:before{ + content:"\e206" +} +.glyphicon-alert:before{ + content:"\e209" +} +.glyphicon-equalizer:before{ + content:"\e210" +} +.glyphicon-king:before{ + content:"\e211" +} +.glyphicon-queen:before{ + content:"\e212" +} +.glyphicon-pawn:before{ + content:"\e213" +} +.glyphicon-bishop:before{ + content:"\e214" +} +.glyphicon-knight:before{ + content:"\e215" +} +.glyphicon-baby-formula:before{ + content:"\e216" +} +.glyphicon-tent:before{ + content:"\26fa" +} +.glyphicon-blackboard:before{ + content:"\e218" +} +.glyphicon-bed:before{ + content:"\e219" +} +.glyphicon-apple:before{ + content:"\f8ff" +} +.glyphicon-erase:before{ + content:"\e221" +} +.glyphicon-hourglass:before{ + content:"\231b" +} +.glyphicon-lamp:before{ + content:"\e223" +} +.glyphicon-duplicate:before{ + content:"\e224" +} +.glyphicon-piggy-bank:before{ + content:"\e225" +} +.glyphicon-scissors:before{ + content:"\e226" +} +.glyphicon-bitcoin:before{ + content:"\e227" +} +.glyphicon-btc:before{ + content:"\e227" +} +.glyphicon-xbt:before{ + content:"\e227" +} +.glyphicon-yen:before{ + content:"\00a5" +} +.glyphicon-jpy:before{ + content:"\00a5" +} +.glyphicon-ruble:before{ + content:"\20bd" +} +.glyphicon-rub:before{ + content:"\20bd" +} +.glyphicon-scale:before{ + content:"\e230" +} +.glyphicon-ice-lolly:before{ + content:"\e231" +} +.glyphicon-ice-lolly-tasted:before{ + content:"\e232" +} +.glyphicon-education:before{ + content:"\e233" +} +.glyphicon-option-horizontal:before{ + content:"\e234" +} +.glyphicon-option-vertical:before{ + content:"\e235" +} +.glyphicon-menu-hamburger:before{ + content:"\e236" +} +.glyphicon-modal-window:before{ + content:"\e237" +} +.glyphicon-oil:before{ + content:"\e238" +} +.glyphicon-grain:before{ + content:"\e239" +} +.glyphicon-sunglasses:before{ + content:"\e240" +} +.glyphicon-text-size:before{ + content:"\e241" +} +.glyphicon-text-color:before{ + content:"\e242" +} +.glyphicon-text-background:before{ + content:"\e243" +} +.glyphicon-object-align-top:before{ + content:"\e244" +} +.glyphicon-object-align-bottom:before{ + content:"\e245" +} +.glyphicon-object-align-horizontal:before{ + content:"\e246" +} +.glyphicon-object-align-left:before{ + content:"\e247" +} +.glyphicon-object-align-vertical:before{ + content:"\e248" +} +.glyphicon-object-align-right:before{ + content:"\e249" +} +.glyphicon-triangle-right:before{ + content:"\e250" +} +.glyphicon-triangle-left:before{ + content:"\e251" +} +.glyphicon-triangle-bottom:before{ + content:"\e252" +} +.glyphicon-triangle-top:before{ + content:"\e253" +} +.glyphicon-console:before{ + content:"\e254" +} +.glyphicon-superscript:before{ + content:"\e255" +} +.glyphicon-subscript:before{ + content:"\e256" +} +.glyphicon-menu-left:before{ + content:"\e257" +} +.glyphicon-menu-right:before{ + content:"\e258" +} +.glyphicon-menu-down:before{ + content:"\e259" +} +.glyphicon-menu-up:before{ + content:"\e260" +} +*{ + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box +} +:after,:before{ + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box +} +html{ + font-size:10px; + -webkit-tap-highlight-color:rgba(0,0,0,0) +} +body{ + font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; + font-size:14px; + line-height:1.42857143; + color:#333; + background-color:#fff +} +button,input,select,textarea{ + font-family:inherit; + font-size:inherit; + line-height:inherit +} +a{ + color:#337ab7; + text-decoration:none +} +a:focus,a:hover{ + color:#23527c; + text-decoration:underline +} +a:focus{ + outline:5px auto -webkit-focus-ring-color; + outline-offset:-2px +} +figure{ + margin:0 +} +img{ + vertical-align:middle +} +.carousel-inner>.item>a>img,.carousel-inner>.item>img,.img-responsive,.thumbnail a>img,.thumbnail>img{ + display:block; + max-width:100%; + height:auto +} +.img-rounded{ + border-radius:6px +} +.img-thumbnail{ + display:inline-block; + max-width:100%; + height:auto; + padding:4px; + line-height:1.42857143; + background-color:#fff; + border:1px solid #ddd; + border-radius:4px; + -webkit-transition:all .2s ease-in-out; + -o-transition:all .2s ease-in-out; + transition:all .2s ease-in-out +} +.img-circle{ + border-radius:50% +} +hr{ + margin-top:20px; + margin-bottom:20px; + border:0; + border-top:1px solid #eee +} +.sr-only{ + position:absolute; + width:1px; + height:1px; + padding:0; + margin:-1px; + overflow:hidden; + clip:rect(0,0,0,0); + border:0 +} +.sr-only-focusable:active,.sr-only-focusable:focus{ + position:static; + width:auto; + height:auto; + margin:0; + overflow:visible; + clip:auto +} +[role=button]{ + cursor:pointer +} +.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{ + font-family:inherit; + font-weight:500; + line-height:1.1; + color:inherit +} +.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{ + font-weight:400; + line-height:1; + color:#777 +} +.h1,.h2,.h3,h1,h2,h3{ + margin-top:20px; + margin-bottom:10px +} +.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small{ + font-size:65% +} +.h4,.h5,.h6,h4,h5,h6{ + margin-top:10px; + margin-bottom:10px +} +.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{ + font-size:75% +} +.h1,h1{ + font-size:36px +} +.h2,h2{ + font-size:30px +} +.h3,h3{ + font-size:24px +} +.h4,h4{ + font-size:18px +} +.h5,h5{ + font-size:14px +} +.h6,h6{ + font-size:12px +} +p{ + margin:0 0 10px +} +.lead{ + margin-bottom:20px; + font-size:16px; + font-weight:300; + line-height:1.4 +} +@media (min-width:768px){ + .lead{ + font-size:21px + } +} +.small,small{ + font-size:85% +} +.mark,mark{ + padding:.2em; + background-color:#fcf8e3 +} +.text-left{ + text-align:left +} +.text-right{ + text-align:right +} +.text-center{ + text-align:center +} +.text-justify{ + text-align:justify +} +.text-nowrap{ + white-space:nowrap +} +.text-lowercase{ + text-transform:lowercase +} +.text-uppercase{ + text-transform:uppercase +} +.text-capitalize{ + text-transform:capitalize +} +.text-muted{ + color:#777 +} +.text-primary{ + color:#337ab7 +} +a.text-primary:focus,a.text-primary:hover{ + color:#286090 +} +.text-success{ + color:#3c763d +} +a.text-success:focus,a.text-success:hover{ + color:#2b542c +} +.text-info{ + color:#31708f +} +a.text-info:focus,a.text-info:hover{ + color:#245269 +} +.text-warning{ + color:#8a6d3b +} +a.text-warning:focus,a.text-warning:hover{ + color:#66512c +} +.text-danger{ + color:#a94442 +} +a.text-danger:focus,a.text-danger:hover{ + color:#843534 +} +.bg-primary{ + color:#fff; + background-color:#337ab7 +} +a.bg-primary:focus,a.bg-primary:hover{ + background-color:#286090 +} +.bg-success{ + background-color:#dff0d8 +} +a.bg-success:focus,a.bg-success:hover{ + background-color:#c1e2b3 +} +.bg-info{ + background-color:#d9edf7 +} +a.bg-info:focus,a.bg-info:hover{ + background-color:#afd9ee +} +.bg-warning{ + background-color:#fcf8e3 +} +a.bg-warning:focus,a.bg-warning:hover{ + background-color:#f7ecb5 +} +.bg-danger{ + background-color:#f2dede +} +a.bg-danger:focus,a.bg-danger:hover{ + background-color:#e4b9b9 +} +.page-header{ + padding-bottom:9px; + margin:40px 0 20px; + border-bottom:1px solid #eee +} +ol,ul{ + margin-top:0; + margin-bottom:10px +} +ol ol,ol ul,ul ol,ul ul{ + margin-bottom:0 +} +.list-unstyled{ + padding-left:0; + list-style:none +} +.list-inline{ + padding-left:0; + margin-left:-5px; + list-style:none +} +.list-inline>li{ + display:inline-block; + padding-right:5px; + padding-left:5px +} +dl{ + margin-top:0; + margin-bottom:20px +} +dd,dt{ + line-height:1.42857143 +} +dt{ + font-weight:700 +} +dd{ + margin-left:0 +} +@media (min-width:768px){ + .dl-horizontal dt{ + float:left; + width:160px; + overflow:hidden; + clear:left; + text-align:right; + text-overflow:ellipsis; + white-space:nowrap + } + .dl-horizontal dd{ + margin-left:180px + } +} +abbr[data-original-title],abbr[title]{ + cursor:help; + border-bottom:1px dotted #777 +} +.initialism{ + font-size:90%; + text-transform:uppercase +} +blockquote{ + padding:10px 20px; + margin:0 0 20px; + font-size:17.5px; + border-left:5px solid #eee +} +blockquote ol:last-child,blockquote p:last-child,blockquote ul:last-child{ + margin-bottom:0 +} +blockquote .small,blockquote footer,blockquote small{ + display:block; + font-size:80%; + line-height:1.42857143; + color:#777 +} +blockquote .small:before,blockquote footer:before,blockquote small:before{ + content:'\2014 \00A0' +} +.blockquote-reverse,blockquote.pull-right{ + padding-right:15px; + padding-left:0; + text-align:right; + border-right:5px solid #eee; + border-left:0 +} +.blockquote-reverse .small:before,.blockquote-reverse footer:before,.blockquote-reverse small:before,blockquote.pull-right .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before{ + content:'' +} +.blockquote-reverse .small:after,.blockquote-reverse footer:after,.blockquote-reverse small:after,blockquote.pull-right .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after{ + content:'\00A0 \2014' +} +address{ + margin-bottom:20px; + font-style:normal; + line-height:1.42857143 +} +code,kbd,pre,samp{ + font-family:Menlo,Monaco,Consolas,"Courier New",monospace +} +code{ + padding:2px 4px; + font-size:90%; + color:#c7254e; + background-color:#f9f2f4; + border-radius:4px +} +kbd{ + padding:2px 4px; + font-size:90%; + color:#fff; + background-color:#333; + border-radius:3px; + -webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.25); + box-shadow:inset 0 -1px 0 rgba(0,0,0,.25) +} +kbd kbd{ + padding:0; + font-size:100%; + font-weight:700; + -webkit-box-shadow:none; + box-shadow:none +} +pre{ + display:block; + padding:9.5px; + margin:0 0 10px; + font-size:13px; + line-height:1.42857143; + color:#333; + word-break:break-all; + word-wrap:break-word; + background-color:#f5f5f5; + border:1px solid #ccc; + border-radius:4px +} +pre code{ + padding:0; + font-size:inherit; + color:inherit; + white-space:pre-wrap; + background-color:transparent; + border-radius:0 +} +.pre-scrollable{ + max-height:340px; + overflow-y:scroll +} +.container{ + padding-right:15px; + padding-left:15px; + margin-right:auto; + margin-left:auto +} +@media (min-width:768px){ + .container{ + width:750px + } +} +@media (min-width:992px){ + .container{ + width:970px + } +} +@media (min-width:1200px){ + .container{ + width:1170px + } +} +.container-fluid{ + padding-right:15px; + padding-left:15px; + margin-right:auto; + margin-left:auto +} +.row{ + margin-right:-15px; + margin-left:-15px +} +.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{ + position:relative; + min-height:1px; + padding-right:15px; + padding-left:15px +} +.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{ + float:left +} +.col-xs-12{ + width:100% +} +.col-xs-11{ + width:91.66666667% +} +.col-xs-10{ + width:83.33333333% +} +.col-xs-9{ + width:75% +} +.col-xs-8{ + width:66.66666667% +} +.col-xs-7{ + width:58.33333333% +} +.col-xs-6{ + width:50% +} +.col-xs-5{ + width:41.66666667% +} +.col-xs-4{ + width:33.33333333% +} +.col-xs-3{ + width:25% +} +.col-xs-2{ + width:16.66666667% +} +.col-xs-1{ + width:8.33333333% +} +.col-xs-pull-12{ + right:100% +} +.col-xs-pull-11{ + right:91.66666667% +} +.col-xs-pull-10{ + right:83.33333333% +} +.col-xs-pull-9{ + right:75% +} +.col-xs-pull-8{ + right:66.66666667% +} +.col-xs-pull-7{ + right:58.33333333% +} +.col-xs-pull-6{ + right:50% +} +.col-xs-pull-5{ + right:41.66666667% +} +.col-xs-pull-4{ + right:33.33333333% +} +.col-xs-pull-3{ + right:25% +} +.col-xs-pull-2{ + right:16.66666667% +} +.col-xs-pull-1{ + right:8.33333333% +} +.col-xs-pull-0{ + right:auto +} +.col-xs-push-12{ + left:100% +} +.col-xs-push-11{ + left:91.66666667% +} +.col-xs-push-10{ + left:83.33333333% +} +.col-xs-push-9{ + left:75% +} +.col-xs-push-8{ + left:66.66666667% +} +.col-xs-push-7{ + left:58.33333333% +} +.col-xs-push-6{ + left:50% +} +.col-xs-push-5{ + left:41.66666667% +} +.col-xs-push-4{ + left:33.33333333% +} +.col-xs-push-3{ + left:25% +} +.col-xs-push-2{ + left:16.66666667% +} +.col-xs-push-1{ + left:8.33333333% +} +.col-xs-push-0{ + left:auto +} +.col-xs-offset-12{ + margin-left:100% +} +.col-xs-offset-11{ + margin-left:91.66666667% +} +.col-xs-offset-10{ + margin-left:83.33333333% +} +.col-xs-offset-9{ + margin-left:75% +} +.col-xs-offset-8{ + margin-left:66.66666667% +} +.col-xs-offset-7{ + margin-left:58.33333333% +} +.col-xs-offset-6{ + margin-left:50% +} +.col-xs-offset-5{ + margin-left:41.66666667% +} +.col-xs-offset-4{ + margin-left:33.33333333% +} +.col-xs-offset-3{ + margin-left:25% +} +.col-xs-offset-2{ + margin-left:16.66666667% +} +.col-xs-offset-1{ + margin-left:8.33333333% +} +.col-xs-offset-0{ + margin-left:0 +} +@media (min-width:768px){ + .col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9{ + float:left + } + .col-sm-12{ + width:100% + } + .col-sm-11{ + width:91.66666667% + } + .col-sm-10{ + width:83.33333333% + } + .col-sm-9{ + width:75% + } + .col-sm-8{ + width:66.66666667% + } + .col-sm-7{ + width:58.33333333% + } + .col-sm-6{ + width:50% + } + .col-sm-5{ + width:41.66666667% + } + .col-sm-4{ + width:33.33333333% + } + .col-sm-3{ + width:25% + } + .col-sm-2{ + width:16.66666667% + } + .col-sm-1{ + width:8.33333333% + } + .col-sm-pull-12{ + right:100% + } + .col-sm-pull-11{ + right:91.66666667% + } + .col-sm-pull-10{ + right:83.33333333% + } + .col-sm-pull-9{ + right:75% + } + .col-sm-pull-8{ + right:66.66666667% + } + .col-sm-pull-7{ + right:58.33333333% + } + .col-sm-pull-6{ + right:50% + } + .col-sm-pull-5{ + right:41.66666667% + } + .col-sm-pull-4{ + right:33.33333333% + } + .col-sm-pull-3{ + right:25% + } + .col-sm-pull-2{ + right:16.66666667% + } + .col-sm-pull-1{ + right:8.33333333% + } + .col-sm-pull-0{ + right:auto + } + .col-sm-push-12{ + left:100% + } + .col-sm-push-11{ + left:91.66666667% + } + .col-sm-push-10{ + left:83.33333333% + } + .col-sm-push-9{ + left:75% + } + .col-sm-push-8{ + left:66.66666667% + } + .col-sm-push-7{ + left:58.33333333% + } + .col-sm-push-6{ + left:50% + } + .col-sm-push-5{ + left:41.66666667% + } + .col-sm-push-4{ + left:33.33333333% + } + .col-sm-push-3{ + left:25% + } + .col-sm-push-2{ + left:16.66666667% + } + .col-sm-push-1{ + left:8.33333333% + } + .col-sm-push-0{ + left:auto + } + .col-sm-offset-12{ + margin-left:100% + } + .col-sm-offset-11{ + margin-left:91.66666667% + } + .col-sm-offset-10{ + margin-left:83.33333333% + } + .col-sm-offset-9{ + margin-left:75% + } + .col-sm-offset-8{ + margin-left:66.66666667% + } + .col-sm-offset-7{ + margin-left:58.33333333% + } + .col-sm-offset-6{ + margin-left:50% + } + .col-sm-offset-5{ + margin-left:41.66666667% + } + .col-sm-offset-4{ + margin-left:33.33333333% + } + .col-sm-offset-3{ + margin-left:25% + } + .col-sm-offset-2{ + margin-left:16.66666667% + } + .col-sm-offset-1{ + margin-left:8.33333333% + } + .col-sm-offset-0{ + margin-left:0 + } +} +@media (min-width:992px){ + .col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9{ + float:left + } + .col-md-12{ + width:100% + } + .col-md-11{ + width:91.66666667% + } + .col-md-10{ + width:83.33333333% + } + .col-md-9{ + width:75% + } + .col-md-8{ + width:66.66666667% + } + .col-md-7{ + width:58.33333333% + } + .col-md-6{ + width:50% + } + .col-md-5{ + width:41.66666667% + } + .col-md-4{ + width:33.33333333% + } + .col-md-3{ + width:25% + } + .col-md-2{ + width:16.66666667% + } + .col-md-1{ + width:8.33333333% + } + .col-md-pull-12{ + right:100% + } + .col-md-pull-11{ + right:91.66666667% + } + .col-md-pull-10{ + right:83.33333333% + } + .col-md-pull-9{ + right:75% + } + .col-md-pull-8{ + right:66.66666667% + } + .col-md-pull-7{ + right:58.33333333% + } + .col-md-pull-6{ + right:50% + } + .col-md-pull-5{ + right:41.66666667% + } + .col-md-pull-4{ + right:33.33333333% + } + .col-md-pull-3{ + right:25% + } + .col-md-pull-2{ + right:16.66666667% + } + .col-md-pull-1{ + right:8.33333333% + } + .col-md-pull-0{ + right:auto + } + .col-md-push-12{ + left:100% + } + .col-md-push-11{ + left:91.66666667% + } + .col-md-push-10{ + left:83.33333333% + } + .col-md-push-9{ + left:75% + } + .col-md-push-8{ + left:66.66666667% + } + .col-md-push-7{ + left:58.33333333% + } + .col-md-push-6{ + left:50% + } + .col-md-push-5{ + left:41.66666667% + } + .col-md-push-4{ + left:33.33333333% + } + .col-md-push-3{ + left:25% + } + .col-md-push-2{ + left:16.66666667% + } + .col-md-push-1{ + left:8.33333333% + } + .col-md-push-0{ + left:auto + } + .col-md-offset-12{ + margin-left:100% + } + .col-md-offset-11{ + margin-left:91.66666667% + } + .col-md-offset-10{ + margin-left:83.33333333% + } + .col-md-offset-9{ + margin-left:75% + } + .col-md-offset-8{ + margin-left:66.66666667% + } + .col-md-offset-7{ + margin-left:58.33333333% + } + .col-md-offset-6{ + margin-left:50% + } + .col-md-offset-5{ + margin-left:41.66666667% + } + .col-md-offset-4{ + margin-left:33.33333333% + } + .col-md-offset-3{ + margin-left:25% + } + .col-md-offset-2{ + margin-left:16.66666667% + } + .col-md-offset-1{ + margin-left:8.33333333% + } + .col-md-offset-0{ + margin-left:0 + } +} +@media (min-width:1200px){ + .col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9{ + float:left + } + .col-lg-12{ + width:100% + } + .col-lg-11{ + width:91.66666667% + } + .col-lg-10{ + width:83.33333333% + } + .col-lg-9{ + width:75% + } + .col-lg-8{ + width:66.66666667% + } + .col-lg-7{ + width:58.33333333% + } + .col-lg-6{ + width:50% + } + .col-lg-5{ + width:41.66666667% + } + .col-lg-4{ + width:33.33333333% + } + .col-lg-3{ + width:25% + } + .col-lg-2{ + width:16.66666667% + } + .col-lg-1{ + width:8.33333333% + } + .col-lg-pull-12{ + right:100% + } + .col-lg-pull-11{ + right:91.66666667% + } + .col-lg-pull-10{ + right:83.33333333% + } + .col-lg-pull-9{ + right:75% + } + .col-lg-pull-8{ + right:66.66666667% + } + .col-lg-pull-7{ + right:58.33333333% + } + .col-lg-pull-6{ + right:50% + } + .col-lg-pull-5{ + right:41.66666667% + } + .col-lg-pull-4{ + right:33.33333333% + } + .col-lg-pull-3{ + right:25% + } + .col-lg-pull-2{ + right:16.66666667% + } + .col-lg-pull-1{ + right:8.33333333% + } + .col-lg-pull-0{ + right:auto + } + .col-lg-push-12{ + left:100% + } + .col-lg-push-11{ + left:91.66666667% + } + .col-lg-push-10{ + left:83.33333333% + } + .col-lg-push-9{ + left:75% + } + .col-lg-push-8{ + left:66.66666667% + } + .col-lg-push-7{ + left:58.33333333% + } + .col-lg-push-6{ + left:50% + } + .col-lg-push-5{ + left:41.66666667% + } + .col-lg-push-4{ + left:33.33333333% + } + .col-lg-push-3{ + left:25% + } + .col-lg-push-2{ + left:16.66666667% + } + .col-lg-push-1{ + left:8.33333333% + } + .col-lg-push-0{ + left:auto + } + .col-lg-offset-12{ + margin-left:100% + } + .col-lg-offset-11{ + margin-left:91.66666667% + } + .col-lg-offset-10{ + margin-left:83.33333333% + } + .col-lg-offset-9{ + margin-left:75% + } + .col-lg-offset-8{ + margin-left:66.66666667% + } + .col-lg-offset-7{ + margin-left:58.33333333% + } + .col-lg-offset-6{ + margin-left:50% + } + .col-lg-offset-5{ + margin-left:41.66666667% + } + .col-lg-offset-4{ + margin-left:33.33333333% + } + .col-lg-offset-3{ + margin-left:25% + } + .col-lg-offset-2{ + margin-left:16.66666667% + } + .col-lg-offset-1{ + margin-left:8.33333333% + } + .col-lg-offset-0{ + margin-left:0 + } +} +table{ + background-color:transparent +} +caption{ + padding-top:8px; + padding-bottom:8px; + color:#777; + text-align:left +} +th{ + text-align:left +} +.table{ + width:100%; + max-width:100%; + margin-bottom:20px +} +.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{ + padding:8px; + line-height:1.42857143; + vertical-align:top; + border-top:1px solid #ddd +} +.table>thead>tr>th{ + vertical-align:bottom; + border-bottom:2px solid #ddd +} +.table>caption+thead>tr:first-child>td,.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>td,.table>thead:first-child>tr:first-child>th{ + border-top:0 +} +.table>tbody+tbody{ + border-top:2px solid #ddd +} +.table .table{ + background-color:#fff +} +.table-condensed>tbody>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>thead>tr>th{ + padding:5px +} +.table-bordered{ + border:1px solid #ddd +} +.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{ + border:1px solid #ddd +} +.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{ + border-bottom-width:2px +} +.table-striped>tbody>tr:nth-of-type(odd){ + background-color:#f9f9f9 +} +.table-hover>tbody>tr:hover{ + background-color:#f5f5f5 +} +table col[class*=col-]{ + position:static; + display:table-column; + float:none +} +table td[class*=col-],table th[class*=col-]{ + position:static; + display:table-cell; + float:none +} +.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>thead>tr>td.active,.table>thead>tr>th.active{ + background-color:#f5f5f5 +} +.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr.active:hover>th,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover{ + background-color:#e8e8e8 +} +.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>thead>tr>td.success,.table>thead>tr>th.success{ + background-color:#dff0d8 +} +.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr.success:hover>th,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover{ + background-color:#d0e9c6 +} +.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>thead>tr>td.info,.table>thead>tr>th.info{ + background-color:#d9edf7 +} +.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr.info:hover>th,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover{ + background-color:#c4e3f3 +} +.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{ + background-color:#fcf8e3 +} +.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{ + background-color:#faf2cc +} +.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>thead>tr>td.danger,.table>thead>tr>th.danger{ + background-color:#f2dede +} +.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr.danger:hover>th,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover{ + background-color:#ebcccc +} +.table-responsive{ + min-height:.01%; + overflow-x:auto +} +@media screen and (max-width:767px){ + .table-responsive{ + width:100%; + margin-bottom:15px; + overflow-y:hidden; + -ms-overflow-style:-ms-autohiding-scrollbar; + border:1px solid #ddd + } + .table-responsive>.table{ + margin-bottom:0 + } + .table-responsive>.table>tbody>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>thead>tr>th{ + white-space:nowrap + } + .table-responsive>.table-bordered{ + border:0 + } + .table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>thead>tr>th:first-child{ + border-left:0 + } + .table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>thead>tr>th:last-child{ + border-right:0 + } + .table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th{ + border-bottom:0 + } +} +fieldset{ + min-width:0; + padding:0; + margin:0; + border:0 +} +legend{ + display:block; + width:100%; + padding:0; + margin-bottom:20px; + font-size:21px; + line-height:inherit; + color:#333; + border:0; + border-bottom:1px solid #e5e5e5 +} +label{ + display:inline-block; + max-width:100%; + margin-bottom:5px; + font-weight:700 +} +input[type=search]{ + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box +} +input[type=checkbox],input[type=radio]{ + margin:4px 0 0; + margin-top:1px\9; + line-height:normal +} +input[type=file]{ + display:block +} +input[type=range]{ + display:block; + width:100% +} +select[multiple],select[size]{ + height:auto +} +input[type=file]:focus,input[type=checkbox]:focus,input[type=radio]:focus{ + outline:5px auto -webkit-focus-ring-color; + outline-offset:-2px +} +output{ + display:block; + padding-top:7px; + font-size:14px; + line-height:1.42857143; + color:#555 +} +.form-control{ + display:block; + width:100%; + height:34px; + padding:6px 12px; + font-size:14px; + line-height:1.42857143; + color:#555; + background-color:#fff; + background-image:none; + border:1px solid #ccc; + border-radius:4px; + -webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075); + box-shadow:inset 0 1px 1px rgba(0,0,0,.075); + -webkit-transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s; + -o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s; + transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s +} +.form-control:focus{ + border-color:#66afe9; + outline:0; + -webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6); + box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6) +} +.form-control::-moz-placeholder{ + color:#999; + opacity:1 +} +.form-control:-ms-input-placeholder{ + color:#999 +} +.form-control::-webkit-input-placeholder{ + color:#999 +} +.form-control::-ms-expand{ + background-color:transparent; + border:0 +} +.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{ + background-color:#eee; + opacity:1 +} +.form-control[disabled],fieldset[disabled] .form-control{ + cursor:not-allowed +} +textarea.form-control{ + height:auto +} +input[type=search]{ + -webkit-appearance:none +} +@media screen and (-webkit-min-device-pixel-ratio:0){ + input[type=date].form-control,input[type=time].form-control,input[type=datetime-local].form-control,input[type=month].form-control{ + line-height:34px + } + .input-group-sm input[type=date],.input-group-sm input[type=time],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],input[type=date].input-sm,input[type=time].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm{ + line-height:30px + } + .input-group-lg input[type=date],.input-group-lg input[type=time],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],input[type=date].input-lg,input[type=time].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg{ + line-height:46px + } +} +.form-group{ + margin-bottom:15px +} +.checkbox,.radio{ + position:relative; + display:block; + margin-top:10px; + margin-bottom:10px +} +.checkbox label,.radio label{ + min-height:20px; + padding-left:20px; + margin-bottom:0; + font-weight:400; + cursor:pointer +} +.checkbox input[type=checkbox],.checkbox-inline input[type=checkbox],.radio input[type=radio],.radio-inline input[type=radio]{ + position:absolute; + margin-top:4px\9; + margin-left:-20px +} +.checkbox+.checkbox,.radio+.radio{ + margin-top:-5px +} +.checkbox-inline,.radio-inline{ + position:relative; + display:inline-block; + padding-left:20px; + margin-bottom:0; + font-weight:400; + vertical-align:middle; + cursor:pointer +} +.checkbox-inline+.checkbox-inline,.radio-inline+.radio-inline{ + margin-top:0; + margin-left:10px +} +fieldset[disabled] input[type=checkbox],fieldset[disabled] input[type=radio],input[type=checkbox].disabled,input[type=checkbox][disabled],input[type=radio].disabled,input[type=radio][disabled]{ + cursor:not-allowed +} +.checkbox-inline.disabled,.radio-inline.disabled,fieldset[disabled] .checkbox-inline,fieldset[disabled] .radio-inline{ + cursor:not-allowed +} +.checkbox.disabled label,.radio.disabled label,fieldset[disabled] .checkbox label,fieldset[disabled] .radio label{ + cursor:not-allowed +} +.form-control-static{ + min-height:34px; + padding-top:7px; + padding-bottom:7px; + margin-bottom:0 +} +.form-control-static.input-lg,.form-control-static.input-sm{ + padding-right:0; + padding-left:0 +} +.input-sm{ + height:30px; + padding:5px 10px; + font-size:12px; + line-height:1.5; + border-radius:3px +} +select.input-sm{ + height:30px; + line-height:30px +} +select[multiple].input-sm,textarea.input-sm{ + height:auto +} +.form-group-sm .form-control{ + height:30px; + padding:5px 10px; + font-size:12px; + line-height:1.5; + border-radius:3px +} +.form-group-sm select.form-control{ + height:30px; + line-height:30px +} +.form-group-sm select[multiple].form-control,.form-group-sm textarea.form-control{ + height:auto +} +.form-group-sm .form-control-static{ + height:30px; + min-height:32px; + padding:6px 10px; + font-size:12px; + line-height:1.5 +} +.input-lg{ + height:46px; + padding:10px 16px; + font-size:18px; + line-height:1.3333333; + border-radius:6px +} +select.input-lg{ + height:46px; + line-height:46px +} +select[multiple].input-lg,textarea.input-lg{ + height:auto +} +.form-group-lg .form-control{ + height:46px; + padding:10px 16px; + font-size:18px; + line-height:1.3333333; + border-radius:6px +} +.form-group-lg select.form-control{ + height:46px; + line-height:46px +} +.form-group-lg select[multiple].form-control,.form-group-lg textarea.form-control{ + height:auto +} +.form-group-lg .form-control-static{ + height:46px; + min-height:38px; + padding:11px 16px; + font-size:18px; + line-height:1.3333333 +} +.has-feedback{ + position:relative +} +.has-feedback .form-control{ + padding-right:42.5px +} +.form-control-feedback{ + position:absolute; + top:0; + right:0; + z-index:2; + display:block; + width:34px; + height:34px; + line-height:34px; + text-align:center; + pointer-events:none +} +.form-group-lg .form-control+.form-control-feedback,.input-group-lg+.form-control-feedback,.input-lg+.form-control-feedback{ + width:46px; + height:46px; + line-height:46px +} +.form-group-sm .form-control+.form-control-feedback,.input-group-sm+.form-control-feedback,.input-sm+.form-control-feedback{ + width:30px; + height:30px; + line-height:30px +} +.has-success .checkbox,.has-success .checkbox-inline,.has-success .control-label,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.checkbox label,.has-success.checkbox-inline label,.has-success.radio label,.has-success.radio-inline label{ + color:#3c763d +} +.has-success .form-control{ + border-color:#3c763d; + -webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075); + box-shadow:inset 0 1px 1px rgba(0,0,0,.075) +} +.has-success .form-control:focus{ + border-color:#2b542c; + -webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168; + box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168 +} +.has-success .input-group-addon{ + color:#3c763d; + background-color:#dff0d8; + border-color:#3c763d +} +.has-success .form-control-feedback{ + color:#3c763d +} +.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning .control-label,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.checkbox label,.has-warning.checkbox-inline label,.has-warning.radio label,.has-warning.radio-inline label{ + color:#8a6d3b +} +.has-warning .form-control{ + border-color:#8a6d3b; + -webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075); + box-shadow:inset 0 1px 1px rgba(0,0,0,.075) +} +.has-warning .form-control:focus{ + border-color:#66512c; + -webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b; + box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b +} +.has-warning .input-group-addon{ + color:#8a6d3b; + background-color:#fcf8e3; + border-color:#8a6d3b +} +.has-warning .form-control-feedback{ + color:#8a6d3b +} +.has-error .checkbox,.has-error .checkbox-inline,.has-error .control-label,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.checkbox label,.has-error.checkbox-inline label,.has-error.radio label,.has-error.radio-inline label{ + color:#a94442 +} +.has-error .form-control{ + border-color:#a94442; + -webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075); + box-shadow:inset 0 1px 1px rgba(0,0,0,.075) +} +.has-error .form-control:focus{ + border-color:#843534; + -webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483; + box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483 +} +.has-error .input-group-addon{ + color:#a94442; + background-color:#f2dede; + border-color:#a94442 +} +.has-error .form-control-feedback{ + color:#a94442 +} +.has-feedback label~.form-control-feedback{ + top:25px +} +.has-feedback label.sr-only~.form-control-feedback{ + top:0 +} +.help-block{ + display:block; + margin-top:5px; + margin-bottom:10px; + color:#737373 +} +@media (min-width:768px){ + .form-inline .form-group{ + display:inline-block; + margin-bottom:0; + vertical-align:middle + } + .form-inline .form-control{ + display:inline-block; + width:auto; + vertical-align:middle + } + .form-inline .form-control-static{ + display:inline-block + } + .form-inline .input-group{ + display:inline-table; + vertical-align:middle + } + .form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn{ + width:auto + } + .form-inline .input-group>.form-control{ + width:100% + } + .form-inline .control-label{ + margin-bottom:0; + vertical-align:middle + } + .form-inline .checkbox,.form-inline .radio{ + display:inline-block; + margin-top:0; + margin-bottom:0; + vertical-align:middle + } + .form-inline .checkbox label,.form-inline .radio label{ + padding-left:0 + } + .form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio]{ + position:relative; + margin-left:0 + } + .form-inline .has-feedback .form-control-feedback{ + top:0 + } +} +.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{ + padding-top:7px; + margin-top:0; + margin-bottom:0 +} +.form-horizontal .checkbox,.form-horizontal .radio{ + min-height:27px +} +.form-horizontal .form-group{ + margin-right:-15px; + margin-left:-15px +} +@media (min-width:768px){ + .form-horizontal .control-label{ + padding-top:7px; + margin-bottom:0; + text-align:right + } +} +.form-horizontal .has-feedback .form-control-feedback{ + right:15px +} +@media (min-width:768px){ + .form-horizontal .form-group-lg .control-label{ + padding-top:11px; + font-size:18px + } +} +@media (min-width:768px){ + .form-horizontal .form-group-sm .control-label{ + padding-top:6px; + font-size:12px + } +} +.btn{ + display:inline-block; + padding:6px 12px; + margin-bottom:0; + font-size:14px; + font-weight:400; + line-height:1.42857143; + text-align:center; + white-space:nowrap; + vertical-align:middle; + -ms-touch-action:manipulation; + touch-action:manipulation; + cursor:pointer; + -webkit-user-select:none; + -moz-user-select:none; + -ms-user-select:none; + user-select:none; + background-image:none; + border:1px solid transparent; + border-radius:4px +} +.btn.active.focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn:active:focus,.btn:focus{ + outline:5px auto -webkit-focus-ring-color; + outline-offset:-2px +} +.btn.focus,.btn:focus,.btn:hover{ + color:#333; + text-decoration:none +} +.btn.active,.btn:active{ + background-image:none; + outline:0; + -webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125); + box-shadow:inset 0 3px 5px rgba(0,0,0,.125) +} +.btn.disabled,.btn[disabled],fieldset[disabled] .btn{ + cursor:not-allowed; + filter:alpha(opacity=65); + -webkit-box-shadow:none; + box-shadow:none; + opacity:.65 +} +a.btn.disabled,fieldset[disabled] a.btn{ + pointer-events:none +} +.btn-default{ + color:#333; + background-color:#fff; + border-color:#ccc +} +.btn-default.focus,.btn-default:focus{ + color:#333; + background-color:#e6e6e6; + border-color:#8c8c8c +} +.btn-default:hover{ + color:#333; + background-color:#e6e6e6; + border-color:#adadad +} +.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{ + color:#333; + background-color:#e6e6e6; + border-color:#adadad +} +.btn-default.active.focus,.btn-default.active:focus,.btn-default.active:hover,.btn-default:active.focus,.btn-default:active:focus,.btn-default:active:hover,.open>.dropdown-toggle.btn-default.focus,.open>.dropdown-toggle.btn-default:focus,.open>.dropdown-toggle.btn-default:hover{ + color:#333; + background-color:#d4d4d4; + border-color:#8c8c8c +} +.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{ + background-image:none +} +.btn-default.disabled.focus,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled].focus,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{ + background-color:#fff; + border-color:#ccc +} +.btn-default .badge{ + color:#fff; + background-color:#333 +} +.btn-primary{ + color:#fff; + background-color:#d11010; + border-color:#c40f0f +} +.btn-primary.focus,.btn-primary:focus{ + color:#fff; + background-color:#b20c0c; + border-color:#c40f0f +} +.btn-primary:hover{ + color:#fff; + background-color:#b20c0c; + border-color:#c40f0f +} +.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{ + color:#fff; + background-color:#b20c0c; + border-color:#c40f0f +} +.btn-primary.active.focus,.btn-primary.active:focus,.btn-primary.active:hover,.btn-primary:active.focus,.btn-primary:active:focus,.btn-primary:active:hover,.open>.dropdown-toggle.btn-primary.focus,.open>.dropdown-toggle.btn-primary:focus,.open>.dropdown-toggle.btn-primary:hover{ + color:#fff; + background-color:#b20c0c; + border-color:#c40f0f +} +.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{ + background-image:none +} +.btn-primary.disabled.focus,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled].focus,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{ + background-color:#b20c0c; + border-color:#c40f0f +} +.btn-primary .badge{ + color:#337ab7; + background-color:#fff +} +.btn-success{ + color:#fff; + background-color:#5cb85c; + border-color:#4cae4c +} +.btn-success.focus,.btn-success:focus{ + color:#fff; + background-color:#449d44; + border-color:#255625 +} +.btn-success:hover{ + color:#fff; + background-color:#449d44; + border-color:#398439 +} +.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{ + color:#fff; + background-color:#449d44; + border-color:#398439 +} +.btn-success.active.focus,.btn-success.active:focus,.btn-success.active:hover,.btn-success:active.focus,.btn-success:active:focus,.btn-success:active:hover,.open>.dropdown-toggle.btn-success.focus,.open>.dropdown-toggle.btn-success:focus,.open>.dropdown-toggle.btn-success:hover{ + color:#fff; + background-color:#398439; + border-color:#255625 +} +.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{ + background-image:none +} +.btn-success.disabled.focus,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled].focus,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{ + background-color:#5cb85c; + border-color:#4cae4c +} +.btn-success .badge{ + color:#5cb85c; + background-color:#fff +} +.btn-info{ + color:#fff; + background-color:#5bc0de; + border-color:#46b8da +} +.btn-info.focus,.btn-info:focus{ + color:#fff; + background-color:#31b0d5; + border-color:#1b6d85 +} +.btn-info:hover{ + color:#fff; + background-color:#31b0d5; + border-color:#269abc +} +.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{ + color:#fff; + background-color:#31b0d5; + border-color:#269abc +} +.btn-info.active.focus,.btn-info.active:focus,.btn-info.active:hover,.btn-info:active.focus,.btn-info:active:focus,.btn-info:active:hover,.open>.dropdown-toggle.btn-info.focus,.open>.dropdown-toggle.btn-info:focus,.open>.dropdown-toggle.btn-info:hover{ + color:#fff; + background-color:#269abc; + border-color:#1b6d85 +} +.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{ + background-image:none +} +.btn-info.disabled.focus,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled].focus,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{ + background-color:#5bc0de; + border-color:#46b8da +} +.btn-info .badge{ + color:#5bc0de; + background-color:#fff +} +.btn-warning{ + color:#fff; + background-color:#f0ad4e; + border-color:#eea236 +} +.btn-warning.focus,.btn-warning:focus{ + color:#fff; + background-color:#ec971f; + border-color:#985f0d +} +.btn-warning:hover{ + color:#fff; + background-color:#ec971f; + border-color:#d58512 +} +.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{ + color:#fff; + background-color:#ec971f; + border-color:#d58512 +} +.btn-warning.active.focus,.btn-warning.active:focus,.btn-warning.active:hover,.btn-warning:active.focus,.btn-warning:active:focus,.btn-warning:active:hover,.open>.dropdown-toggle.btn-warning.focus,.open>.dropdown-toggle.btn-warning:focus,.open>.dropdown-toggle.btn-warning:hover{ + color:#fff; + background-color:#d58512; + border-color:#985f0d +} +.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{ + background-image:none +} +.btn-warning.disabled.focus,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled].focus,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{ + background-color:#f0ad4e; + border-color:#eea236 +} +.btn-warning .badge{ + color:#f0ad4e; + background-color:#fff +} +.btn-danger{ + color:#fff; + background-color:#d9534f; + border-color:#d43f3a +} +.btn-danger.focus,.btn-danger:focus{ + color:#fff; + background-color:#c9302c; + border-color:#761c19 +} +.btn-danger:hover{ + color:#fff; + background-color:#c9302c; + border-color:#ac2925 +} +.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{ + color:#fff; + background-color:#c9302c; + border-color:#ac2925 +} +.btn-danger.active.focus,.btn-danger.active:focus,.btn-danger.active:hover,.btn-danger:active.focus,.btn-danger:active:focus,.btn-danger:active:hover,.open>.dropdown-toggle.btn-danger.focus,.open>.dropdown-toggle.btn-danger:focus,.open>.dropdown-toggle.btn-danger:hover{ + color:#fff; + background-color:#ac2925; + border-color:#761c19 +} +.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{ + background-image:none +} +.btn-danger.disabled.focus,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled].focus,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{ + background-color:#d9534f; + border-color:#d43f3a +} +.btn-danger .badge{ + color:#d9534f; + background-color:#fff +} +.btn-link{ + font-weight:400; + color:#337ab7; + border-radius:0 +} +.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{ + background-color:transparent; + -webkit-box-shadow:none; + box-shadow:none +} +.btn-link,.btn-link:active,.btn-link:focus,.btn-link:hover{ + border-color:transparent +} +.btn-link:focus,.btn-link:hover{ + color:#23527c; + text-decoration:underline; + background-color:transparent +} +.btn-link[disabled]:focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus,fieldset[disabled] .btn-link:hover{ + color:#777; + text-decoration:none +} +.btn-group-lg>.btn,.btn-lg{ + padding:10px 16px; + font-size:18px; + line-height:1.3333333; + border-radius:6px +} +.btn-group-sm>.btn,.btn-sm{ + padding:5px 10px; + font-size:12px; + line-height:1.5; + border-radius:3px +} +.btn-group-xs>.btn,.btn-xs{ + padding:1px 5px; + font-size:12px; + line-height:1.5; + border-radius:3px +} +.btn-block{ + display:block; + width:100% +} +.btn-block+.btn-block{ + margin-top:5px +} +input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{ + width:100% +} +.fade{ + opacity:0; + -webkit-transition:opacity .15s linear; + -o-transition:opacity .15s linear; + transition:opacity .15s linear +} +.fade.in{ + opacity:1 +} +.collapse{ + display:none +} +.collapse.in{ + display:block +} +tr.collapse.in{ + display:table-row +} +tbody.collapse.in{ + display:table-row-group +} +.collapsing{ + position:relative; + height:0; + overflow:hidden; + -webkit-transition-timing-function:ease; + -o-transition-timing-function:ease; + transition-timing-function:ease; + -webkit-transition-duration:.35s; + -o-transition-duration:.35s; + transition-duration:.35s; + -webkit-transition-property:height,visibility; + -o-transition-property:height,visibility; + transition-property:height,visibility +} +.caret{ + display:inline-block; + width:0; + height:0; + margin-left:2px; + vertical-align:middle; + border-top:4px dashed; + border-top:4px solid\9; + border-right:4px solid transparent; + border-left:4px solid transparent +} +.dropdown,.dropup{ + position:relative +} +.dropdown-toggle:focus{ + outline:0 +} +.dropdown-menu{ + position:absolute; + top:100%; + left:0; + z-index:1000; + display:none; + float:left; + min-width:160px; + padding:5px 0; + margin:2px 0 0; + font-size:14px; + text-align:left; + list-style:none; + background-color:#fff; + -webkit-background-clip:padding-box; + background-clip:padding-box; + border:1px solid #ccc; + border:1px solid rgba(0,0,0,.15); + border-radius:4px; + -webkit-box-shadow:0 6px 12px rgba(0,0,0,.175); + box-shadow:0 6px 12px rgba(0,0,0,.175) +} +.dropdown-menu.pull-right{ + right:0; + left:auto +} +.dropdown-menu .divider{ + height:1px; + margin:9px 0; + overflow:hidden; + background-color:#e5e5e5 +} +.dropdown-menu>li>a{ + display:block; + padding:3px 20px; + clear:both; + font-weight:400; + line-height:1.42857143; + color:#333; + white-space:nowrap +} +.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{ + color:#262626; + text-decoration:none; + background-color:#f5f5f5 +} +.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{ + color:#fff; + text-decoration:none; + background-color:#337ab7; + outline:0 +} +.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{ + color:#777 +} +.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{ + text-decoration:none; + cursor:not-allowed; + background-color:transparent; + background-image:none; + filter:progid:DXImageTransform.Microsoft.gradient(enabled=false) +} +.open>.dropdown-menu{ + display:block +} +.open>a{ + outline:0 +} +.dropdown-menu-right{ + right:0; + left:auto +} +.dropdown-menu-left{ + right:auto; + left:0 +} +.dropdown-header{ + display:block; + padding:3px 20px; + font-size:12px; + line-height:1.42857143; + color:#777; + white-space:nowrap +} +.dropdown-backdrop{ + position:fixed; + top:0; + right:0; + bottom:0; + left:0; + z-index:990 +} +.pull-right>.dropdown-menu{ + right:0; + left:auto +} +.dropup .caret,.navbar-fixed-bottom .dropdown .caret{ + content:""; + border-top:0; + border-bottom:4px dashed; + border-bottom:4px solid\9 +} +.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{ + top:auto; + bottom:100%; + margin-bottom:2px +} +@media (min-width:768px){ + .navbar-right .dropdown-menu{ + right:0; + left:auto + } + .navbar-right .dropdown-menu-left{ + right:auto; + left:0 + } +} +.btn-group,.btn-group-vertical{ + position:relative; + display:inline-block; + vertical-align:middle +} +.btn-group-vertical>.btn,.btn-group>.btn{ + position:relative; + float:left +} +.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{ + z-index:2 +} +.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{ + margin-left:-1px +} +.btn-toolbar{ + margin-left:-5px +} +.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{ + float:left +} +.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{ + margin-left:5px +} +.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){ + border-radius:0 +} +.btn-group>.btn:first-child{ + margin-left:0 +} +.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){ + border-top-right-radius:0; + border-bottom-right-radius:0 +} +.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){ + border-top-left-radius:0; + border-bottom-left-radius:0 +} +.btn-group>.btn-group{ + float:left +} +.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{ + border-radius:0 +} +.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{ + border-top-right-radius:0; + border-bottom-right-radius:0 +} +.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{ + border-top-left-radius:0; + border-bottom-left-radius:0 +} +.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{ + outline:0 +} +.btn-group>.btn+.dropdown-toggle{ + padding-right:8px; + padding-left:8px +} +.btn-group>.btn-lg+.dropdown-toggle{ + padding-right:12px; + padding-left:12px +} +.btn-group.open .dropdown-toggle{ + -webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125); + box-shadow:inset 0 3px 5px rgba(0,0,0,.125) +} +.btn-group.open .dropdown-toggle.btn-link{ + -webkit-box-shadow:none; + box-shadow:none +} +.btn .caret{ + margin-left:0 +} +.btn-lg .caret{ + border-width:5px 5px 0; + border-bottom-width:0 +} +.dropup .btn-lg .caret{ + border-width:0 5px 5px +} +.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{ + display:block; + float:none; + width:100%; + max-width:100% +} +.btn-group-vertical>.btn-group>.btn{ + float:none +} +.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{ + margin-top:-1px; + margin-left:0 +} +.btn-group-vertical>.btn:not(:first-child):not(:last-child){ + border-radius:0 +} +.btn-group-vertical>.btn:first-child:not(:last-child){ + border-top-left-radius:4px; + border-top-right-radius:4px; + border-bottom-right-radius:0; + border-bottom-left-radius:0 +} +.btn-group-vertical>.btn:last-child:not(:first-child){ + border-top-left-radius:0; + border-top-right-radius:0; + border-bottom-right-radius:4px; + border-bottom-left-radius:4px +} +.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{ + border-radius:0 +} +.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{ + border-bottom-right-radius:0; + border-bottom-left-radius:0 +} +.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{ + border-top-left-radius:0; + border-top-right-radius:0 +} +.btn-group-justified{ + display:table; + width:100%; + table-layout:fixed; + border-collapse:separate +} +.btn-group-justified>.btn,.btn-group-justified>.btn-group{ + display:table-cell; + float:none; + width:1% +} +.btn-group-justified>.btn-group .btn{ + width:100% +} +.btn-group-justified>.btn-group .dropdown-menu{ + left:auto +} +[data-toggle=buttons]>.btn input[type=checkbox],[data-toggle=buttons]>.btn input[type=radio],[data-toggle=buttons]>.btn-group>.btn input[type=checkbox],[data-toggle=buttons]>.btn-group>.btn input[type=radio]{ + position:absolute; + clip:rect(0,0,0,0); + pointer-events:none +} +.input-group{ + position:relative; + display:table; + border-collapse:separate +} +.input-group[class*=col-]{ + float:none; + padding-right:0; + padding-left:0 +} +.input-group .form-control{ + position:relative; + z-index:2; + float:left; + width:100%; + margin-bottom:0 +} +.input-group .form-control:focus{ + z-index:3 +} +.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{ + height:46px; + padding:10px 16px; + font-size:18px; + line-height:1.3333333; + border-radius:6px +} +select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{ + height:46px; + line-height:46px +} +select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn,textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{ + height:auto +} +.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{ + height:30px; + padding:5px 10px; + font-size:12px; + line-height:1.5; + border-radius:3px +} +select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{ + height:30px; + line-height:30px +} +select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn,textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{ + height:auto +} +.input-group .form-control,.input-group-addon,.input-group-btn{ + display:table-cell +} +.input-group .form-control:not(:first-child):not(:last-child),.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child){ + border-radius:0 +} +.input-group-addon,.input-group-btn{ + width:1%; + white-space:nowrap; + vertical-align:middle +} +.input-group-addon{ + padding:6px 12px; + font-size:14px; + font-weight:400; + line-height:1; + color:#555; + text-align:center; + background-color:#eee; + border:1px solid #ccc; + border-radius:4px +} +.input-group-addon.input-sm{ + padding:5px 10px; + font-size:12px; + border-radius:3px +} +.input-group-addon.input-lg{ + padding:10px 16px; + font-size:18px; + border-radius:6px +} +.input-group-addon input[type=checkbox],.input-group-addon input[type=radio]{ + margin-top:0 +} +.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn-group:not(:last-child)>.btn,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){ + border-top-right-radius:0; + border-bottom-right-radius:0 +} +.input-group-addon:first-child{ + border-right:0 +} +.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle{ + border-top-left-radius:0; + border-bottom-left-radius:0 +} +.input-group-addon:last-child{ + border-left:0 +} +.input-group-btn{ + position:relative; + font-size:0; + white-space:nowrap +} +.input-group-btn>.btn{ + position:relative +} +.input-group-btn>.btn+.btn{ + margin-left:-1px +} +.input-group-btn>.btn:active,.input-group-btn>.btn:focus,.input-group-btn>.btn:hover{ + z-index:2 +} +.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{ + margin-right:-1px +} +.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{ + z-index:2; + margin-left:-1px +} +.nav{ + padding-left:0; + margin-bottom:0; + list-style:none +} +.nav>li{ + position:relative; + display:block +} +.nav>li>a{ + position:relative; + display:block; + padding:10px 15px +} +.nav>li>a:focus,.nav>li>a:hover{ + text-decoration:none; + background-color:#eee +} +.nav>li.disabled>a{ + color:#777 +} +.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{ + color:#777; + text-decoration:none; + cursor:not-allowed; + background-color:transparent +} +.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{ + background-color:#eee; + border-color:#337ab7 +} +.nav .nav-divider{ + height:1px; + margin:9px 0; + overflow:hidden; + background-color:#e5e5e5 +} +.nav>li>a>img{ + max-width:none +} +.nav-tabs{ + border-bottom:1px solid #ddd +} +.nav-tabs>li{ + float:left; + margin-bottom:-1px +} +.nav-tabs>li>a{ + margin-right:2px; + line-height:1.42857143; + border:1px solid transparent; + border-radius:4px 4px 0 0 +} +.nav-tabs>li>a:hover{ + border-color:#eee #eee #ddd +} +.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{ + color:#555; + cursor:default; + background-color:#fff; + border:1px solid #ddd; + border-bottom-color:transparent +} +.nav-tabs.nav-justified{ + width:100%; + border-bottom:0 +} +.nav-tabs.nav-justified>li{ + float:none +} +.nav-tabs.nav-justified>li>a{ + margin-bottom:5px; + text-align:center +} +.nav-tabs.nav-justified>.dropdown .dropdown-menu{ + top:auto; + left:auto +} +@media (min-width:768px){ + .nav-tabs.nav-justified>li{ + display:table-cell; + width:1% + } + .nav-tabs.nav-justified>li>a{ + margin-bottom:0 + } +} +.nav-tabs.nav-justified>li>a{ + margin-right:0; + border-radius:4px +} +.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{ + border:1px solid #ddd +} +@media (min-width:768px){ + .nav-tabs.nav-justified>li>a{ + border-bottom:1px solid #ddd; + border-radius:4px 4px 0 0 + } + .nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{ + border-bottom-color:#fff + } +} +.nav-pills>li{ + float:left +} +.nav-pills>li>a{ + border-radius:4px +} +.nav-pills>li+li{ + margin-left:2px +} +.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{ + color:#fff; + background-color:#337ab7 +} +.nav-stacked>li{ + float:none +} +.nav-stacked>li+li{ + margin-top:2px; + margin-left:0 +} +.nav-justified{ + width:100% +} +.nav-justified>li{ + float:none +} +.nav-justified>li>a{ + margin-bottom:5px; + text-align:center +} +.nav-justified>.dropdown .dropdown-menu{ + top:auto; + left:auto +} +@media (min-width:768px){ + .nav-justified>li{ + display:table-cell; + width:1% + } + .nav-justified>li>a{ + margin-bottom:0 + } +} +.nav-tabs-justified{ + border-bottom:0 +} +.nav-tabs-justified>li>a{ + margin-right:0; + border-radius:4px +} +.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{ + border:1px solid #ddd +} +@media (min-width:768px){ + .nav-tabs-justified>li>a{ + border-bottom:1px solid #ddd; + border-radius:4px 4px 0 0 + } + .nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{ + border-bottom-color:#fff + } +} +.tab-content>.tab-pane{ + display:none +} +.tab-content>.active{ + display:block +} +.nav-tabs .dropdown-menu{ + margin-top:-1px; + border-top-left-radius:0; + border-top-right-radius:0 +} +.navbar{ + position:relative; + min-height:50px; + margin-bottom:20px; + border:1px solid transparent +} +@media (min-width:768px){ + .navbar{ + border-radius:4px + } +} +@media (min-width:768px){ + .navbar-header{ + float:left + } +} +.navbar-collapse{ + padding-right:15px; + padding-left:15px; + overflow-x:visible; + -webkit-overflow-scrolling:touch; + border-top:1px solid transparent; + -webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1); + box-shadow:inset 0 1px 0 rgba(255,255,255,.1) +} +.navbar-collapse.in{ + overflow-y:auto +} +@media (min-width:768px){ + .navbar-collapse{ + width:auto; + border-top:0; + -webkit-box-shadow:none; + box-shadow:none + } + .navbar-collapse.collapse{ + display:block!important; + height:auto!important; + padding-bottom:0; + overflow:visible!important + } + .navbar-collapse.in{ + overflow-y:visible + } + .navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse{ + padding-right:0; + padding-left:0 + } +} +.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{ + max-height:340px +} +@media (max-device-width:480px) and (orientation:landscape){ + .navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{ + max-height:200px + } +} +.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{ + margin-right:-15px; + margin-left:-15px +} +@media (min-width:768px){ + .container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{ + margin-right:0; + margin-left:0 + } +} +.navbar-static-top{ + z-index:1000; + border-width:0 0 1px +} +@media (min-width:768px){ + .navbar-static-top{ + border-radius:0 + } +} +.navbar-fixed-bottom,.navbar-fixed-top{ + position:fixed; + right:0; + left:0; + z-index:1030 +} +@media (min-width:768px){ + .navbar-fixed-bottom,.navbar-fixed-top{ + border-radius:0 + } +} +.navbar-fixed-top{ + top:0; + border-width:0 0 1px +} +.navbar-fixed-bottom{ + bottom:0; + margin-bottom:0; + border-width:1px 0 0 +} +.navbar-brand{ + float:left; + height:50px; + padding:15px 15px; + font-size:18px; + line-height:20px +} +.navbar-brand:focus,.navbar-brand:hover{ + text-decoration:none +} +.navbar-brand>img{ + display:block +} +@media (min-width:768px){ + .navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{ + margin-left:-15px + } +} +.navbar-toggle{ + position:relative; + float:right; + padding:9px 10px; + margin-top:8px; + margin-right:15px; + margin-bottom:8px; + background-color:transparent; + background-image:none; + border:1px solid transparent; + border-radius:4px +} +.navbar-toggle:focus{ + outline:0 +} +.navbar-toggle .icon-bar{ + display:block; + width:22px; + height:2px; + border-radius:1px +} +.navbar-toggle .icon-bar+.icon-bar{ + margin-top:4px +} +@media (min-width:768px){ + .navbar-toggle{ + display:none + } +} +.navbar-nav{ + margin:7.5px -15px +} +.navbar-nav>li>a{ + padding-top:10px; + padding-bottom:10px; + line-height:20px +} +@media (max-width:767px){ + .navbar-nav .open .dropdown-menu{ + position:static; + float:none; + width:auto; + margin-top:0; + background-color:transparent; + border:0; + -webkit-box-shadow:none; + box-shadow:none + } + .navbar-nav .open .dropdown-menu .dropdown-header,.navbar-nav .open .dropdown-menu>li>a{ + padding:5px 15px 5px 25px + } + .navbar-nav .open .dropdown-menu>li>a{ + line-height:20px + } + .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-nav .open .dropdown-menu>li>a:hover{ + background-image:none + } +} +@media (min-width:768px){ + .navbar-nav{ + float:left; + margin:0 + } + .navbar-nav>li{ + float:left + } + .navbar-nav>li>a{ + padding-top:15px; + padding-bottom:15px + } +} +.navbar-form{ + padding:10px 15px; + margin-top:8px; + margin-right:-15px; + margin-bottom:8px; + margin-left:-15px; + border-top:1px solid transparent; + border-bottom:1px solid transparent; + -webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1); + box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1) +} +@media (min-width:768px){ + .navbar-form .form-group{ + display:inline-block; + margin-bottom:0; + vertical-align:middle + } + .navbar-form .form-control{ + display:inline-block; + width:auto; + vertical-align:middle + } + .navbar-form .form-control-static{ + display:inline-block + } + .navbar-form .input-group{ + display:inline-table; + vertical-align:middle + } + .navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn{ + width:auto + } + .navbar-form .input-group>.form-control{ + width:100% + } + .navbar-form .control-label{ + margin-bottom:0; + vertical-align:middle + } + .navbar-form .checkbox,.navbar-form .radio{ + display:inline-block; + margin-top:0; + margin-bottom:0; + vertical-align:middle + } + .navbar-form .checkbox label,.navbar-form .radio label{ + padding-left:0 + } + .navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{ + position:relative; + margin-left:0 + } + .navbar-form .has-feedback .form-control-feedback{ + top:0 + } +} +@media (max-width:767px){ + .navbar-form .form-group{ + margin-bottom:5px + } + .navbar-form .form-group:last-child{ + margin-bottom:0 + } +} +@media (min-width:768px){ + .navbar-form{ + width:auto; + padding-top:0; + padding-bottom:0; + margin-right:0; + margin-left:0; + border:0; + -webkit-box-shadow:none; + box-shadow:none + } +} +.navbar-nav>li>.dropdown-menu{ + margin-top:0; + border-top-left-radius:0; + border-top-right-radius:0 +} +.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{ + margin-bottom:0; + border-top-left-radius:4px; + border-top-right-radius:4px; + border-bottom-right-radius:0; + border-bottom-left-radius:0 +} +.navbar-btn{ + margin-top:8px; + margin-bottom:8px +} +.navbar-btn.btn-sm{ + margin-top:10px; + margin-bottom:10px +} +.navbar-btn.btn-xs{ + margin-top:14px; + margin-bottom:14px +} +.navbar-text{ + margin-top:15px; + margin-bottom:15px +} +@media (min-width:768px){ + .navbar-text{ + float:left; + margin-right:15px; + margin-left:15px + } +} +@media (min-width:768px){ + .navbar-left{ + float:left!important + } + .navbar-right{ + float:right!important; + margin-right:-15px + } + .navbar-right~.navbar-right{ + margin-right:0 + } +} +.navbar-default{ + background-color:#f8f8f8; + border-color:#e7e7e7 +} +.navbar-default .navbar-brand{ + color:#777 +} +.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{ + color:#5e5e5e; + background-color:transparent +} +.navbar-default .navbar-text{ + color:#777 +} +.navbar-default .navbar-nav>li>a{ + color:#777 +} +.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{ + color:#333; + background-color:transparent +} +.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{ + color:#555; + background-color:#e7e7e7 +} +.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{ + color:#ccc; + background-color:transparent +} +.navbar-default .navbar-toggle{ + border-color:#ddd +} +.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{ + background-color:#ddd +} +.navbar-default .navbar-toggle .icon-bar{ + background-color:#888 +} +.navbar-default .navbar-collapse,.navbar-default .navbar-form{ + border-color:#e7e7e7 +} +.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{ + color:#555; + background-color:#e7e7e7 +} +@media (max-width:767px){ + .navbar-default .navbar-nav .open .dropdown-menu>li>a{ + color:#777 + } + .navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{ + color:#333; + background-color:transparent + } + .navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{ + color:#555; + background-color:#e7e7e7 + } + .navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{ + color:#ccc; + background-color:transparent + } +} +.navbar-default .navbar-link{ + color:#777 +} +.navbar-default .navbar-link:hover{ + color:#333 +} +.navbar-default .btn-link{ + color:#777 +} +.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{ + color:#333 +} +.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{ + color:#ccc +} +.navbar-inverse{ + background-color:#222; + border-color:#080808 +} +.navbar-inverse .navbar-brand{ + color:#9d9d9d +} +.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{ + color:#fff; + background-color:transparent +} +.navbar-inverse .navbar-text{ + color:#9d9d9d +} +.navbar-inverse .navbar-nav>li>a{ + color:#9d9d9d +} +.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{ + color:#fff; + background-color:transparent +} +.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{ + color:#fff; + background-color:#080808 +} +.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{ + color:#444; + background-color:transparent +} +.navbar-inverse .navbar-toggle{ + border-color:#333 +} +.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{ + background-color:#333 +} +.navbar-inverse .navbar-toggle .icon-bar{ + background-color:#fff +} +.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{ + border-color:#101010 +} +.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{ + color:#fff; + background-color:#080808 +} +@media (max-width:767px){ + .navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{ + border-color:#080808 + } + .navbar-inverse .navbar-nav .open .dropdown-menu .divider{ + background-color:#080808 + } + .navbar-inverse .navbar-nav .open .dropdown-menu>li>a{ + color:#9d9d9d + } + .navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{ + color:#fff; + background-color:transparent + } + .navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{ + color:#fff; + background-color:#080808 + } + .navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{ + color:#444; + background-color:transparent + } +} +.navbar-inverse .navbar-link{ + color:#9d9d9d +} +.navbar-inverse .navbar-link:hover{ + color:#fff +} +.navbar-inverse .btn-link{ + color:#9d9d9d +} +.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{ + color:#fff +} +.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{ + color:#444 +} +.breadcrumb{ + padding:8px 15px; + margin-bottom:20px; + list-style:none; + background-color:#f5f5f5; + border-radius:4px +} +.breadcrumb>li{ + display:inline-block +} +.breadcrumb>li+li:before{ + padding:0 5px; + color:#ccc; + content:"/\00a0" +} +.breadcrumb>.active{ + color:#777 +} +.pagination{ + display:inline-block; + padding-left:0; + margin:20px 0; + border-radius:4px +} +.pagination>li{ + display:inline +} +.pagination>li>a,.pagination>li>span{ + position:relative; + float:left; + padding:6px 12px; + margin-left:-1px; + line-height:1.42857143; + color:#337ab7; + text-decoration:none; + background-color:#fff; + border:1px solid #ddd +} +.pagination>li:first-child>a,.pagination>li:first-child>span{ + margin-left:0; + border-top-left-radius:4px; + border-bottom-left-radius:4px +} +.pagination>li:last-child>a,.pagination>li:last-child>span{ + border-top-right-radius:4px; + border-bottom-right-radius:4px +} +.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{ + z-index:2; + color:#23527c; + background-color:#eee; + border-color:#ddd +} +.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{ + z-index:3; + color:#fff; + cursor:default; + background-color:#337ab7; + border-color:#337ab7 +} +.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{ + color:#777; + cursor:not-allowed; + background-color:#fff; + border-color:#ddd +} +.pagination-lg>li>a,.pagination-lg>li>span{ + padding:10px 16px; + font-size:18px; + line-height:1.3333333 +} +.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{ + border-top-left-radius:6px; + border-bottom-left-radius:6px +} +.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{ + border-top-right-radius:6px; + border-bottom-right-radius:6px +} +.pagination-sm>li>a,.pagination-sm>li>span{ + padding:5px 10px; + font-size:12px; + line-height:1.5 +} +.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{ + border-top-left-radius:3px; + border-bottom-left-radius:3px +} +.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{ + border-top-right-radius:3px; + border-bottom-right-radius:3px +} +.pager{ + padding-left:0; + margin:20px 0; + text-align:center; + list-style:none +} +.pager li{ + display:inline +} +.pager li>a,.pager li>span{ + display:inline-block; + padding:5px 14px; + background-color:#fff; + border:1px solid #ddd; + border-radius:15px +} +.pager li>a:focus,.pager li>a:hover{ + text-decoration:none; + background-color:#eee +} +.pager .next>a,.pager .next>span{ + float:right +} +.pager .previous>a,.pager .previous>span{ + float:left +} +.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{ + color:#777; + cursor:not-allowed; + background-color:#fff +} +.label{ + display:inline; + padding:.2em .6em .3em; + font-size:75%; + font-weight:700; + line-height:1; + color:#fff; + text-align:center; + white-space:nowrap; + vertical-align:baseline; + border-radius:.25em +} +a.label:focus,a.label:hover{ + color:#fff; + text-decoration:none; + cursor:pointer +} +.label:empty{ + display:none +} +.btn .label{ + position:relative; + top:-1px +} +.label-default{ + background-color:#777 +} +.label-default[href]:focus,.label-default[href]:hover{ + background-color:#5e5e5e +} +.label-primary{ + background-color:#337ab7 +} +.label-primary[href]:focus,.label-primary[href]:hover{ + background-color:#286090 +} +.label-success{ + background-color:#5cb85c +} +.label-success[href]:focus,.label-success[href]:hover{ + background-color:#449d44 +} +.label-info{ + background-color:#5bc0de +} +.label-info[href]:focus,.label-info[href]:hover{ + background-color:#31b0d5 +} +.label-warning{ + background-color:#f0ad4e +} +.label-warning[href]:focus,.label-warning[href]:hover{ + background-color:#ec971f +} +.label-danger{ + background-color:#d9534f +} +.label-danger[href]:focus,.label-danger[href]:hover{ + background-color:#c9302c +} +.badge{ + display:inline-block; + min-width:10px; + padding:3px 7px; + font-size:12px; + font-weight:700; + line-height:1; + color:#fff; + text-align:center; + white-space:nowrap; + vertical-align:middle; + background-color:#777; + border-radius:10px +} +.badge:empty{ + display:none +} +.btn .badge{ + position:relative; + top:-1px +} +.btn-group-xs>.btn .badge,.btn-xs .badge{ + top:0; + padding:1px 5px +} +a.badge:focus,a.badge:hover{ + color:#fff; + text-decoration:none; + cursor:pointer +} +.list-group-item.active>.badge,.nav-pills>.active>a>.badge{ + color:#337ab7; + background-color:#fff +} +.list-group-item>.badge{ + float:right +} +.list-group-item>.badge+.badge{ + margin-right:5px +} +.nav-pills>li>a>.badge{ + margin-left:3px +} +.jumbotron{ + padding-top:30px; + padding-bottom:30px; + margin-bottom:30px; + color:inherit; + background-color:#eee +} +.jumbotron .h1,.jumbotron h1{ + color:inherit +} +.jumbotron p{ + margin-bottom:15px; + font-size:21px; + font-weight:200 +} +.jumbotron>hr{ + border-top-color:#d5d5d5 +} +.container .jumbotron,.container-fluid .jumbotron{ + padding-right:15px; + padding-left:15px; + border-radius:6px +} +.jumbotron .container{ + max-width:100% +} +@media screen and (min-width:768px){ + .jumbotron{ + padding-top:48px; + padding-bottom:48px + } + .container .jumbotron,.container-fluid .jumbotron{ + padding-right:60px; + padding-left:60px + } + .jumbotron .h1,.jumbotron h1{ + font-size:63px + } +} +.thumbnail{ + display:block; + padding:4px; + margin-bottom:20px; + line-height:1.42857143; + background-color:#fff; + border:1px solid #ddd; + border-radius:4px; + -webkit-transition:border .2s ease-in-out; + -o-transition:border .2s ease-in-out; + transition:border .2s ease-in-out +} +.thumbnail a>img,.thumbnail>img{ + margin-right:auto; + margin-left:auto +} +a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{ + border-color:#337ab7 +} +.thumbnail .caption{ + padding:9px; + color:#333 +} +.alert{ + padding:15px; + margin-bottom:20px; + border:1px solid transparent; + border-radius:4px +} +.alert h4{ + margin-top:0; + color:inherit +} +.alert .alert-link{ + font-weight:700 +} +.alert>p,.alert>ul{ + margin-bottom:0 +} +.alert>p+p{ + margin-top:5px +} +.alert-dismissable,.alert-dismissible{ + padding-right:35px +} +.alert-dismissable .close,.alert-dismissible .close{ + position:relative; + top:-2px; + right:-21px; + color:inherit +} +.alert-success{ + color:#3c763d; + background-color:#dff0d8; + border-color:#d6e9c6 +} +.alert-success hr{ + border-top-color:#c9e2b3 +} +.alert-success .alert-link{ + color:#2b542c +} +.alert-info{ + color:#31708f; + background-color:#d9edf7; + border-color:#bce8f1 +} +.alert-info hr{ + border-top-color:#a6e1ec +} +.alert-info .alert-link{ + color:#245269 +} +.alert-warning{ + color:#8a6d3b; + background-color:#fcf8e3; + border-color:#faebcc +} +.alert-warning hr{ + border-top-color:#f7e1b5 +} +.alert-warning .alert-link{ + color:#66512c +} +.alert-danger{ + color:#a94442; + background-color:#f2dede; + border-color:#ebccd1 +} +.alert-danger hr{ + border-top-color:#e4b9c0 +} +.alert-danger .alert-link{ + color:#843534 +} +@-webkit-keyframes progress-bar-stripes{ + from{ + background-position:40px 0 + } + to{ + background-position:0 0 + } +} +@-o-keyframes progress-bar-stripes{ + from{ + background-position:40px 0 + } + to{ + background-position:0 0 + } +} +@keyframes progress-bar-stripes{ + from{ + background-position:40px 0 + } + to{ + background-position:0 0 + } +} +.progress{ + height:20px; + margin-bottom:20px; + overflow:hidden; + background-color:#f5f5f5; + border-radius:4px; + -webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1); + box-shadow:inset 0 1px 2px rgba(0,0,0,.1) +} +.progress-bar{ + float:left; + width:0; + height:100%; + font-size:12px; + line-height:20px; + color:#fff; + text-align:center; + background-color:#337ab7; + -webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.15); + box-shadow:inset 0 -1px 0 rgba(0,0,0,.15); + -webkit-transition:width .6s ease; + -o-transition:width .6s ease; + transition:width .6s ease +} +.progress-bar-striped,.progress-striped .progress-bar{ + background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent); + background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent); + background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent); + -webkit-background-size:40px 40px; + background-size:40px 40px +} +.progress-bar.active,.progress.active .progress-bar{ + -webkit-animation:progress-bar-stripes 2s linear infinite; + -o-animation:progress-bar-stripes 2s linear infinite; + animation:progress-bar-stripes 2s linear infinite +} +.progress-bar-success{ + background-color:#5cb85c +} +.progress-striped .progress-bar-success{ + background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent); + background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent); + background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent) +} +.progress-bar-info{ + background-color:#5bc0de +} +.progress-striped .progress-bar-info{ + background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent); + background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent); + background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent) +} +.progress-bar-warning{ + background-color:#f0ad4e +} +.progress-striped .progress-bar-warning{ + background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent); + background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent); + background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent) +} +.progress-bar-danger{ + background-color:#d9534f +} +.progress-striped .progress-bar-danger{ + background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent); + background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent); + background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent) +} +.media{ + margin-top:15px +} +.media:first-child{ + margin-top:0 +} +.media,.media-body{ + overflow:hidden; + zoom:1 +} +.media-body{ + width:10000px +} +.media-object{ + display:block +} +.media-object.img-thumbnail{ + max-width:none +} +.media-right,.media>.pull-right{ + padding-left:10px +} +.media-left,.media>.pull-left{ + padding-right:10px +} +.media-body,.media-left,.media-right{ + display:table-cell; + vertical-align:top +} +.media-middle{ + vertical-align:middle +} +.media-bottom{ + vertical-align:bottom +} +.media-heading{ + margin-top:0; + margin-bottom:5px +} +.media-list{ + padding-left:0; + list-style:none +} +.list-group{ + padding-left:0; + margin-bottom:20px +} +.list-group-item{ + position:relative; + display:block; + padding:10px 15px; + margin-bottom:-1px; + background-color:#fff; + border:1px solid #ddd +} +.list-group-item:first-child{ + border-top-left-radius:4px; + border-top-right-radius:4px +} +.list-group-item:last-child{ + margin-bottom:0; + border-bottom-right-radius:4px; + border-bottom-left-radius:4px +} +a.list-group-item,button.list-group-item{ + color:#555 +} +a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{ + color:#333 +} +a.list-group-item:focus,a.list-group-item:hover,button.list-group-item:focus,button.list-group-item:hover{ + color:#555; + text-decoration:none; + background-color:#f5f5f5 +} +button.list-group-item{ + width:100%; + text-align:left +} +.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{ + color:#777; + cursor:not-allowed; + background-color:#eee +} +.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{ + color:inherit +} +.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{ + color:#777 +} +.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{ + z-index:2; + color:#fff; + background-color:#337ab7; + border-color:#337ab7 +} +.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{ + color:inherit +} +.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{ + color:#c7ddef +} +.list-group-item-success{ + color:#3c763d; + background-color:#dff0d8 +} +a.list-group-item-success,button.list-group-item-success{ + color:#3c763d +} +a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{ + color:inherit +} +a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{ + color:#3c763d; + background-color:#d0e9c6 +} +a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{ + color:#fff; + background-color:#3c763d; + border-color:#3c763d +} +.list-group-item-info{ + color:#31708f; + background-color:#d9edf7 +} +a.list-group-item-info,button.list-group-item-info{ + color:#31708f +} +a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{ + color:inherit +} +a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{ + color:#31708f; + background-color:#c4e3f3 +} +a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{ + color:#fff; + background-color:#31708f; + border-color:#31708f +} +.list-group-item-warning{ + color:#8a6d3b; + background-color:#fcf8e3 +} +a.list-group-item-warning,button.list-group-item-warning{ + color:#8a6d3b +} +a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{ + color:inherit +} +a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{ + color:#8a6d3b; + background-color:#faf2cc +} +a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{ + color:#fff; + background-color:#8a6d3b; + border-color:#8a6d3b +} +.list-group-item-danger{ + color:#a94442; + background-color:#f2dede +} +a.list-group-item-danger,button.list-group-item-danger{ + color:#a94442 +} +a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{ + color:inherit +} +a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{ + color:#a94442; + background-color:#ebcccc +} +a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{ + color:#fff; + background-color:#a94442; + border-color:#a94442 +} +.list-group-item-heading{ + margin-top:0; + margin-bottom:5px +} +.list-group-item-text{ + margin-bottom:0; + line-height:1.3 +} +.panel{ + margin-bottom:20px; + background-color:#fff; + border:1px solid transparent; + border-radius:4px; + -webkit-box-shadow:0 1px 1px rgba(0,0,0,.05); + box-shadow:0 1px 1px rgba(0,0,0,.05) +} +.panel-body{ + padding:15px +} +.panel-heading{ + padding:10px 15px; + border-bottom:1px solid transparent; + border-top-left-radius:3px; + border-top-right-radius:3px +} +.panel-heading>.dropdown .dropdown-toggle{ + color:inherit +} +.panel-title{ + margin-top:0; + margin-bottom:0; + font-size:16px; + color:inherit +} +.panel-title>.small,.panel-title>.small>a,.panel-title>a,.panel-title>small,.panel-title>small>a{ + color:inherit +} +.panel-footer{ + padding:10px 15px; + background-color:#f5f5f5; + border-top:1px solid #ddd; + border-bottom-right-radius:3px; + border-bottom-left-radius:3px +} +.panel>.list-group,.panel>.panel-collapse>.list-group{ + margin-bottom:0 +} +.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{ + border-width:1px 0; + border-radius:0 +} +.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{ + border-top:0; + border-top-left-radius:3px; + border-top-right-radius:3px +} +.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{ + border-bottom:0; + border-bottom-right-radius:3px; + border-bottom-left-radius:3px +} +.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{ + border-top-left-radius:0; + border-top-right-radius:0 +} +.panel-heading+.list-group .list-group-item:first-child{ + border-top-width:0 +} +.list-group+.panel-footer{ + border-top-width:0 +} +.panel>.panel-collapse>.table,.panel>.table,.panel>.table-responsive>.table{ + margin-bottom:0 +} +.panel>.panel-collapse>.table caption,.panel>.table caption,.panel>.table-responsive>.table caption{ + padding-right:15px; + padding-left:15px +} +.panel>.table-responsive:first-child>.table:first-child,.panel>.table:first-child{ + border-top-left-radius:3px; + border-top-right-radius:3px +} +.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{ + border-top-left-radius:3px; + border-top-right-radius:3px +} +.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{ + border-top-left-radius:3px +} +.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{ + border-top-right-radius:3px +} +.panel>.table-responsive:last-child>.table:last-child,.panel>.table:last-child{ + border-bottom-right-radius:3px; + border-bottom-left-radius:3px +} +.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{ + border-bottom-right-radius:3px; + border-bottom-left-radius:3px +} +.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{ + border-bottom-left-radius:3px +} +.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{ + border-bottom-right-radius:3px +} +.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{ + border-top:1px solid #ddd +} +.panel>.table>tbody:first-child>tr:first-child td,.panel>.table>tbody:first-child>tr:first-child th{ + border-top:0 +} +.panel>.table-bordered,.panel>.table-responsive>.table-bordered{ + border:0 +} +.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child{ + border-left:0 +} +.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child{ + border-right:0 +} +.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th{ + border-bottom:0 +} +.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{ + border-bottom:0 +} +.panel>.table-responsive{ + margin-bottom:0; + border:0 +} +.panel-group{ + margin-bottom:20px +} +.panel-group .panel{ + margin-bottom:0; + border-radius:4px +} +.panel-group .panel+.panel{ + margin-top:5px +} +.panel-group .panel-heading{ + border-bottom:0 +} +.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{ + border-top:1px solid #ddd +} +.panel-group .panel-footer{ + border-top:0 +} +.panel-group .panel-footer+.panel-collapse .panel-body{ + border-bottom:1px solid #ddd +} +.panel-default{ + border-color:#ddd +} +.panel-default>.panel-heading{ + color:#333; + background-color:#f5f5f5; + border-color:#ddd +} +.panel-default>.panel-heading+.panel-collapse>.panel-body{ + border-top-color:#ddd +} +.panel-default>.panel-heading .badge{ + color:#f5f5f5; + background-color:#333 +} +.panel-default>.panel-footer+.panel-collapse>.panel-body{ + border-bottom-color:#ddd +} +.panel-primary{ + border-color:#337ab7 +} +.panel-primary>.panel-heading{ + color:#fff; + background-color:#337ab7; + border-color:#337ab7 +} +.panel-primary>.panel-heading+.panel-collapse>.panel-body{ + border-top-color:#337ab7 +} +.panel-primary>.panel-heading .badge{ + color:#337ab7; + background-color:#fff +} +.panel-primary>.panel-footer+.panel-collapse>.panel-body{ + border-bottom-color:#337ab7 +} +.panel-success{ + border-color:#d6e9c6 +} +.panel-success>.panel-heading{ + color:#3c763d; + background-color:#dff0d8; + border-color:#d6e9c6 +} +.panel-success>.panel-heading+.panel-collapse>.panel-body{ + border-top-color:#d6e9c6 +} +.panel-success>.panel-heading .badge{ + color:#dff0d8; + background-color:#3c763d +} +.panel-success>.panel-footer+.panel-collapse>.panel-body{ + border-bottom-color:#d6e9c6 +} +.panel-info{ + border-color:#bce8f1 +} +.panel-info>.panel-heading{ + color:#31708f; + background-color:#d9edf7; + border-color:#bce8f1 +} +.panel-info>.panel-heading+.panel-collapse>.panel-body{ + border-top-color:#bce8f1 +} +.panel-info>.panel-heading .badge{ + color:#d9edf7; + background-color:#31708f +} +.panel-info>.panel-footer+.panel-collapse>.panel-body{ + border-bottom-color:#bce8f1 +} +.panel-warning{ + border-color:#faebcc +} +.panel-warning>.panel-heading{ + color:#8a6d3b; + background-color:#fcf8e3; + border-color:#faebcc +} +.panel-warning>.panel-heading+.panel-collapse>.panel-body{ + border-top-color:#faebcc +} +.panel-warning>.panel-heading .badge{ + color:#fcf8e3; + background-color:#8a6d3b +} +.panel-warning>.panel-footer+.panel-collapse>.panel-body{ + border-bottom-color:#faebcc +} +.panel-danger{ + border-color:#ebccd1 +} +.panel-danger>.panel-heading{ + color:#a94442; + background-color:#f2dede; + border-color:#ebccd1 +} +.panel-danger>.panel-heading+.panel-collapse>.panel-body{ + border-top-color:#ebccd1 +} +.panel-danger>.panel-heading .badge{ + color:#f2dede; + background-color:#a94442 +} +.panel-danger>.panel-footer+.panel-collapse>.panel-body{ + border-bottom-color:#ebccd1 +} +.embed-responsive{ + position:relative; + display:block; + height:0; + padding:0; + overflow:hidden +} +.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{ + position:absolute; + top:0; + bottom:0; + left:0; + width:100%; + height:100%; + border:0 +} +.embed-responsive-16by9{ + padding-bottom:56.25% +} +.embed-responsive-4by3{ + padding-bottom:75% +} +.well{ + min-height:20px; + padding:19px; + margin-bottom:20px; + background-color:#f5f5f5; + border:1px solid #e3e3e3; + border-radius:4px; + -webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.05); + box-shadow:inset 0 1px 1px rgba(0,0,0,.05) +} +.well blockquote{ + border-color:#ddd; + border-color:rgba(0,0,0,.15) +} +.well-lg{ + padding:24px; + border-radius:6px +} +.well-sm{ + padding:9px; + border-radius:3px +} +.close{ + float:right; + font-size:21px; + font-weight:700; + line-height:1; + color:#000; + text-shadow:0 1px 0 #fff; + filter:alpha(opacity=20); + opacity:.2 +} +.close:focus,.close:hover{ + color:#000; + text-decoration:none; + cursor:pointer; + filter:alpha(opacity=50); + opacity:.5 +} +button.close{ + -webkit-appearance:none; + padding:0; + cursor:pointer; + background:0 0; + border:0 +} +.modal-open{ + overflow:hidden +} +.modal{ + position:fixed; + top:0; + right:0; + bottom:0; + left:0; + z-index:1050; + display:none; + overflow:hidden; + -webkit-overflow-scrolling:touch; + outline:0 +} +.modal.fade .modal-dialog{ + -webkit-transition:-webkit-transform .3s ease-out; + -o-transition:-o-transform .3s ease-out; + transition:transform .3s ease-out; + -webkit-transform:translate(0,-25%); + -ms-transform:translate(0,-25%); + -o-transform:translate(0,-25%); + transform:translate(0,-25%) +} +.modal.in .modal-dialog{ + -webkit-transform:translate(0,0); + -ms-transform:translate(0,0); + -o-transform:translate(0,0); + transform:translate(0,0) +} +.modal-open .modal{ + overflow-x:hidden; + overflow-y:auto +} +.modal-dialog{ + position:relative; + width:auto; + margin:10px +} +.modal-content{ + position:relative; + background-color:#fff; + -webkit-background-clip:padding-box; + background-clip:padding-box; + border:1px solid #999; + border:1px solid rgba(0,0,0,.2); + border-radius:6px; + outline:0; + -webkit-box-shadow:0 3px 9px rgba(0,0,0,.5); + box-shadow:0 3px 9px rgba(0,0,0,.5) +} +.modal-backdrop{ + position:fixed; + top:0; + right:0; + bottom:0; + left:0; + z-index:1040; + background-color:#000 +} +.modal-backdrop.fade{ + filter:alpha(opacity=0); + opacity:0 +} +.modal-backdrop.in{ + filter:alpha(opacity=50); + opacity:.5 +} +.modal-header{ + padding:15px; + border-bottom:1px solid #e5e5e5 +} +.modal-header .close{ + margin-top:-2px +} +.modal-title{ + margin:0; + line-height:1.42857143 +} +.modal-body{ + position:relative; + padding:15px +} +.modal-footer{ + padding:15px; + text-align:right; + border-top:1px solid #e5e5e5 +} +.modal-footer .btn+.btn{ + margin-bottom:0; + margin-left:5px +} +.modal-footer .btn-group .btn+.btn{ + margin-left:-1px +} +.modal-footer .btn-block+.btn-block{ + margin-left:0 +} +.modal-scrollbar-measure{ + position:absolute; + top:-9999px; + width:50px; + height:50px; + overflow:scroll +} +@media (min-width:768px){ + .modal-dialog{ + width:600px; + margin:30px auto + } + .modal-content{ + -webkit-box-shadow:0 5px 15px rgba(0,0,0,.5); + box-shadow:0 5px 15px rgba(0,0,0,.5) + } + .modal-sm{ + width:300px + } +} +@media (min-width:992px){ + .modal-lg{ + width:900px + } +} +.tooltip{ + position:absolute; + z-index:1070; + display:block; + font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; + font-size:12px; + font-style:normal; + font-weight:400; + line-height:1.42857143; + text-align:left; + text-align:start; + text-decoration:none; + text-shadow:none; + text-transform:none; + letter-spacing:normal; + word-break:normal; + word-spacing:normal; + word-wrap:normal; + white-space:normal; + filter:alpha(opacity=0); + opacity:0; + line-break:auto +} +.tooltip.in{ + filter:alpha(opacity=90); + opacity:.9 +} +.tooltip.top{ + padding:5px 0; + margin-top:-3px +} +.tooltip.right{ + padding:0 5px; + margin-left:3px +} +.tooltip.bottom{ + padding:5px 0; + margin-top:3px +} +.tooltip.left{ + padding:0 5px; + margin-left:-3px +} +.tooltip-inner{ + max-width:200px; + padding:3px 8px; + color:#fff; + text-align:center; + background-color:#000; + border-radius:4px +} +.tooltip-arrow{ + position:absolute; + width:0; + height:0; + border-color:transparent; + border-style:solid +} +.tooltip.top .tooltip-arrow{ + bottom:0; + left:50%; + margin-left:-5px; + border-width:5px 5px 0; + border-top-color:#000 +} +.tooltip.top-left .tooltip-arrow{ + right:5px; + bottom:0; + margin-bottom:-5px; + border-width:5px 5px 0; + border-top-color:#000 +} +.tooltip.top-right .tooltip-arrow{ + bottom:0; + left:5px; + margin-bottom:-5px; + border-width:5px 5px 0; + border-top-color:#000 +} +.tooltip.right .tooltip-arrow{ + top:50%; + left:0; + margin-top:-5px; + border-width:5px 5px 5px 0; + border-right-color:#000 +} +.tooltip.left .tooltip-arrow{ + top:50%; + right:0; + margin-top:-5px; + border-width:5px 0 5px 5px; + border-left-color:#000 +} +.tooltip.bottom .tooltip-arrow{ + top:0; + left:50%; + margin-left:-5px; + border-width:0 5px 5px; + border-bottom-color:#000 +} +.tooltip.bottom-left .tooltip-arrow{ + top:0; + right:5px; + margin-top:-5px; + border-width:0 5px 5px; + border-bottom-color:#000 +} +.tooltip.bottom-right .tooltip-arrow{ + top:0; + left:5px; + margin-top:-5px; + border-width:0 5px 5px; + border-bottom-color:#000 +} +.popover{ + position:absolute; + top:0; + left:0; + z-index:1060; + display:none; + max-width:276px; + padding:1px; + font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; + font-size:14px; + font-style:normal; + font-weight:400; + line-height:1.42857143; + text-align:left; + text-align:start; + text-decoration:none; + text-shadow:none; + text-transform:none; + letter-spacing:normal; + word-break:normal; + word-spacing:normal; + word-wrap:normal; + white-space:normal; + background-color:#fff; + -webkit-background-clip:padding-box; + background-clip:padding-box; + border:1px solid #ccc; + border:1px solid rgba(0,0,0,.2); + border-radius:6px; + -webkit-box-shadow:0 5px 10px rgba(0,0,0,.2); + box-shadow:0 5px 10px rgba(0,0,0,.2); + line-break:auto +} +.popover.top{ + margin-top:-10px +} +.popover.right{ + margin-left:10px +} +.popover.bottom{ + margin-top:10px +} +.popover.left{ + margin-left:-10px +} +.popover-title{ + padding:8px 14px; + margin:0; + font-size:14px; + background-color:#f7f7f7; + border-bottom:1px solid #ebebeb; + border-radius:5px 5px 0 0 +} +.popover-content{ + padding:9px 14px +} +.popover>.arrow,.popover>.arrow:after{ + position:absolute; + display:block; + width:0; + height:0; + border-color:transparent; + border-style:solid +} +.popover>.arrow{ + border-width:11px +} +.popover>.arrow:after{ + content:""; + border-width:10px +} +.popover.top>.arrow{ + bottom:-11px; + left:50%; + margin-left:-11px; + border-top-color:#999; + border-top-color:rgba(0,0,0,.25); + border-bottom-width:0 +} +.popover.top>.arrow:after{ + bottom:1px; + margin-left:-10px; + content:" "; + border-top-color:#fff; + border-bottom-width:0 +} +.popover.right>.arrow{ + top:50%; + left:-11px; + margin-top:-11px; + border-right-color:#999; + border-right-color:rgba(0,0,0,.25); + border-left-width:0 +} +.popover.right>.arrow:after{ + bottom:-10px; + left:1px; + content:" "; + border-right-color:#fff; + border-left-width:0 +} +.popover.bottom>.arrow{ + top:-11px; + left:50%; + margin-left:-11px; + border-top-width:0; + border-bottom-color:#999; + border-bottom-color:rgba(0,0,0,.25) +} +.popover.bottom>.arrow:after{ + top:1px; + margin-left:-10px; + content:" "; + border-top-width:0; + border-bottom-color:#fff +} +.popover.left>.arrow{ + top:50%; + right:-11px; + margin-top:-11px; + border-right-width:0; + border-left-color:#999; + border-left-color:rgba(0,0,0,.25) +} +.popover.left>.arrow:after{ + right:1px; + bottom:-10px; + content:" "; + border-right-width:0; + border-left-color:#fff +} +.carousel{ + position:relative +} +.carousel-inner{ + position:relative; + width:100%; + overflow:hidden +} +.carousel-inner>.item{ + position:relative; + display:none; + -webkit-transition:.6s ease-in-out left; + -o-transition:.6s ease-in-out left; + transition:.6s ease-in-out left +} +.carousel-inner>.item>a>img,.carousel-inner>.item>img{ + line-height:1 +} +@media all and (transform-3d),(-webkit-transform-3d){ + .carousel-inner>.item{ + -webkit-transition:-webkit-transform .6s ease-in-out; + -o-transition:-o-transform .6s ease-in-out; + transition:transform .6s ease-in-out; + -webkit-backface-visibility:hidden; + backface-visibility:hidden; + -webkit-perspective:1000px; + perspective:1000px + } + .carousel-inner>.item.active.right,.carousel-inner>.item.next{ + left:0; + -webkit-transform:translate3d(100%,0,0); + transform:translate3d(100%,0,0) + } + .carousel-inner>.item.active.left,.carousel-inner>.item.prev{ + left:0; + -webkit-transform:translate3d(-100%,0,0); + transform:translate3d(-100%,0,0) + } + .carousel-inner>.item.active,.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right{ + left:0; + -webkit-transform:translate3d(0,0,0); + transform:translate3d(0,0,0) + } +} +.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{ + display:block +} +.carousel-inner>.active{ + left:0 +} +.carousel-inner>.next,.carousel-inner>.prev{ + position:absolute; + top:0; + width:100% +} +.carousel-inner>.next{ + left:100% +} +.carousel-inner>.prev{ + left:-100% +} +.carousel-inner>.next.left,.carousel-inner>.prev.right{ + left:0 +} +.carousel-inner>.active.left{ + left:-100% +} +.carousel-inner>.active.right{ + left:100% +} +.carousel-control{ + position:absolute; + top:0; + bottom:0; + left:0; + width:15%; + font-size:20px; + color:#fff; + text-align:center; + text-shadow:0 1px 2px rgba(0,0,0,.6); + background-color:rgba(0,0,0,0); + filter:alpha(opacity=50); + opacity:.5 +} +.carousel-control.left{ + background-image:-webkit-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%); + background-image:-o-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%); + background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,.0001))); + background-image:linear-gradient(to right,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%); + filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1); + background-repeat:repeat-x +} +.carousel-control.right{ + right:0; + left:auto; + background-image:-webkit-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%); + background-image:-o-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%); + background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.0001)),to(rgba(0,0,0,.5))); + background-image:linear-gradient(to right,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%); + filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1); + background-repeat:repeat-x +} +.carousel-control:focus,.carousel-control:hover{ + color:#fff; + text-decoration:none; + filter:alpha(opacity=90); + outline:0; + opacity:.9 +} +.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{ + position:absolute; + top:50%; + z-index:5; + display:inline-block; + margin-top:-10px +} +.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{ + left:50%; + margin-left:-10px +} +.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{ + right:50%; + margin-right:-10px +} +.carousel-control .icon-next,.carousel-control .icon-prev{ + width:20px; + height:20px; + font-family:serif; + line-height:1 +} +.carousel-control .icon-prev:before{ + content:'\2039' +} +.carousel-control .icon-next:before{ + content:'\203a' +} +.carousel-indicators{ + position:absolute; + bottom:10px; + left:50%; + z-index:15; + width:60%; + padding-left:0; + margin-left:-30%; + text-align:center; + list-style:none +} +.carousel-indicators li{ + display:inline-block; + width:10px; + height:10px; + margin:1px; + text-indent:-999px; + cursor:pointer; + background-color:#000\9; + background-color:rgba(0,0,0,0); + border:1px solid #fff; + border-radius:10px +} +.carousel-indicators .active{ + width:12px; + height:12px; + margin:0; + background-color:#fff +} +.carousel-caption{ + position:absolute; + right:15%; + bottom:20px; + left:15%; + z-index:10; + padding-top:20px; + padding-bottom:20px; + color:#fff; + text-align:center; + text-shadow:0 1px 2px rgba(0,0,0,.6) +} +.carousel-caption .btn{ + text-shadow:none +} +@media screen and (min-width:768px){ + .carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{ + width:30px; + height:30px; + margin-top:-10px; + font-size:30px + } + .carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{ + margin-left:-10px + } + .carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{ + margin-right:-10px + } + .carousel-caption{ + right:20%; + left:20%; + padding-bottom:30px + } + .carousel-indicators{ + bottom:20px + } +} +.btn-group-vertical>.btn-group:after,.btn-group-vertical>.btn-group:before,.btn-toolbar:after,.btn-toolbar:before,.clearfix:after,.clearfix:before,.container-fluid:after,.container-fluid:before,.container:after,.container:before,.dl-horizontal dd:after,.dl-horizontal dd:before,.form-horizontal .form-group:after,.form-horizontal .form-group:before,.modal-footer:after,.modal-footer:before,.modal-header:after,.modal-header:before,.nav:after,.nav:before,.navbar-collapse:after,.navbar-collapse:before,.navbar-header:after,.navbar-header:before,.navbar:after,.navbar:before,.pager:after,.pager:before,.panel-body:after,.panel-body:before,.row:after,.row:before{ + display:table; + content:" " +} +.btn-group-vertical>.btn-group:after,.btn-toolbar:after,.clearfix:after,.container-fluid:after,.container:after,.dl-horizontal dd:after,.form-horizontal .form-group:after,.modal-footer:after,.modal-header:after,.nav:after,.navbar-collapse:after,.navbar-header:after,.navbar:after,.pager:after,.panel-body:after,.row:after{ + clear:both +} +.center-block{ + display:block; + margin-right:auto; + margin-left:auto +} +.pull-right{ + float:right!important +} +.pull-left{ + float:left!important +} +.hide{ + display:none!important +} +.show{ + display:block!important +} +.invisible{ + visibility:hidden +} +.text-hide{ + font:0/0 a; + color:transparent; + text-shadow:none; + background-color:transparent; + border:0 +} +.hidden{ + display:none!important +} +.affix{ + position:fixed +} +@-ms-viewport{ + width:device-width +} +.visible-lg,.visible-md,.visible-sm,.visible-xs{ + display:none!important +} +.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block{ + display:none!important +} +@media (max-width:767px){ + .visible-xs{ + display:block!important + } + table.visible-xs{ + display:table!important + } + tr.visible-xs{ + display:table-row!important + } + td.visible-xs,th.visible-xs{ + display:table-cell!important + } +} +@media (max-width:767px){ + .visible-xs-block{ + display:block!important + } +} +@media (max-width:767px){ + .visible-xs-inline{ + display:inline!important + } +} +@media (max-width:767px){ + .visible-xs-inline-block{ + display:inline-block!important + } +} +@media (min-width:768px) and (max-width:991px){ + .visible-sm{ + display:block!important + } + table.visible-sm{ + display:table!important + } + tr.visible-sm{ + display:table-row!important + } + td.visible-sm,th.visible-sm{ + display:table-cell!important + } +} +@media (min-width:768px) and (max-width:991px){ + .visible-sm-block{ + display:block!important + } +} +@media (min-width:768px) and (max-width:991px){ + .visible-sm-inline{ + display:inline!important + } +} +@media (min-width:768px) and (max-width:991px){ + .visible-sm-inline-block{ + display:inline-block!important + } +} +@media (min-width:992px) and (max-width:1199px){ + .visible-md{ + display:block!important + } + table.visible-md{ + display:table!important + } + tr.visible-md{ + display:table-row!important + } + td.visible-md,th.visible-md{ + display:table-cell!important + } +} +@media (min-width:992px) and (max-width:1199px){ + .visible-md-block{ + display:block!important + } +} +@media (min-width:992px) and (max-width:1199px){ + .visible-md-inline{ + display:inline!important + } +} +@media (min-width:992px) and (max-width:1199px){ + .visible-md-inline-block{ + display:inline-block!important + } +} +@media (min-width:1200px){ + .visible-lg{ + display:block!important + } + table.visible-lg{ + display:table!important + } + tr.visible-lg{ + display:table-row!important + } + td.visible-lg,th.visible-lg{ + display:table-cell!important + } +} +@media (min-width:1200px){ + .visible-lg-block{ + display:block!important + } +} +@media (min-width:1200px){ + .visible-lg-inline{ + display:inline!important + } +} +@media (min-width:1200px){ + .visible-lg-inline-block{ + display:inline-block!important + } +} +@media (max-width:767px){ + .hidden-xs{ + display:none!important + } +} +@media (min-width:768px) and (max-width:991px){ + .hidden-sm{ + display:none!important + } +} +@media (min-width:992px) and (max-width:1199px){ + .hidden-md{ + display:none!important + } +} +@media (min-width:1200px){ + .hidden-lg{ + display:none!important + } +} +.visible-print{ + display:none!important +} +@media print{ + .visible-print{ + display:block!important + } + table.visible-print{ + display:table!important + } + tr.visible-print{ + display:table-row!important + } + td.visible-print,th.visible-print{ + display:table-cell!important + } +} +.visible-print-block{ + display:none!important +} +@media print{ + .visible-print-block{ + display:block!important + } +} +.visible-print-inline{ + display:none!important +} +@media print{ + .visible-print-inline{ + display:inline!important + } +} +.visible-print-inline-block{ + display:none!important +} +@media print{ + .visible-print-inline-block{ + display:inline-block!important + } +} +@media print{ + .hidden-print{ + display:none!important + } +} +/*# sourceMappingURL=bootstrap.min.css.map */ + diff --git a/themes/black/client/src/css/01-main.css b/themes/black/client/src/css/01-main.css new file mode 100644 index 00000000..318b90e2 --- /dev/null +++ b/themes/black/client/src/css/01-main.css @@ -0,0 +1,77 @@ +body { + /*background-image: url("//*img//*LargeTriangles.svg");*/ + /*background-image: url("//*img//*RandomizedPattern.svg");*/ + /*background-image: url("//*img//*background.svg");*/ + background-color:#000000;*/ +} +canvas{ + position:absolute; + top:0; + left:0; +} +.authelia-brand { + font-weight: bold; + font-style: italic; + color: #ffffff +} +.poweredby-block { + margin: 0px 30px; + margin-top: 10px; + padding-top: 15px; + border-top: 1px solid rgba(0, 0, 0, 0.15); + +} +.poweredby { + font-size: 0.7em; + color: white; +} +/* notifications */ +.notification { + padding: 10px; + margin: 15px 0px; + border-radius: 6px; + display: none; + position: absolute; +} +.notification img { + width: 24px; + margin-right: 10px; +} +.notification i, +.notification span { + display:table-cell; + vertical-align:middle; +} +.info { + border: 1px solid #9cb1ff; + background-color: rgb(192, 220, 255); +} +.success { + border: 1px solid #65ec7c; + background-color: rgb(163, 255, 157); +} +.error { + border: 1px solid #ffa3a3; + background-color: rgb(255, 175, 175); +} +.warning { + border: 1px solid #ffd743; + background-color: rgb(255, 230, 143); +} +.bottom-right-links { + text-align: right; + margin-top: 10px; + font-size: 0.8em; + color: white; +} +.header { + background-color: #000000; + color: white; + margin: 0px; +} +.body { + padding: 10px; +} +h1 { + font-size: 25px; +} diff --git a/themes/black/client/src/css/02-login.css b/themes/black/client/src/css/02-login.css new file mode 100644 index 00000000..a6984267 --- /dev/null +++ b/themes/black/client/src/css/02-login.css @@ -0,0 +1,136 @@ +.form-signin +{ + margin: 0 auto; +} + +.form-signin .form-signin-heading, .form-signin .checkbox +{ + margin-bottom: 10px; +} + +.form-signin .checkbox +{ + font-weight: normal; +} + +.form-signin .form-control +{ + position: relative; + font-size: 16px; + height: auto; + padding: 10px; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +.form-signin .form-control:focus +{ + z-index: 2; +} +.form-signin input[type="text"] +{ + margin-bottom: -1px; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} +.form-signin input[type="password"] +{ + /* margin-bottom: 10px; */ + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.account-wall +{ + border: 1px solid #000; + margin-top: 20px; + padding-bottom: 20px; + background-color: #000000; + -moz-box-shadow: 0px 2px 2px rgba(0, 0, 0, 1); + -webkit-box-shadow: 0px 2px 2px rgba(0, 0, 0, 1); + box-shadow: 0px 2px 2px rgba(0, 0, 0, 1); +} +.account-wall h1 +{ + margin-bottom: 15px; + margin-top: 15px; + font-weight: 800; + display: block; + text-align: center; +} +.account-wall h3 +{ + display: block; + text-align: center; +} +.account-wall p +{ + text-align: center; + margin: 10px; + color: white; +} +.account-wall .form-inputs +{ + margin-bottom: 10px; + border-color: #b20c0c; +} +.account-wall hr { + border-color: #c5c5c5; +} + +.header-img +{ + width: 96px; + height: 96px; + margin: 0 auto 10px; + display: block; + -moz-border-radius: 50%; + -webkit-border-radius: 50%; + border-radius: 50%; +} + +.link +{ + margin-top: 10px; + color: white; +} + +.btn-primary.totp +{ + background-color: rgb(102, 135, 162); +} + +.btn-primary.u2f +{ + background-color: rgb(83, 149, 204); +} + +.u2f-token { + text-align: center; +} + +.u2f-token img { + width: 70px; +} + +.keep-me-logged-in { + margin-top: 10px; + font-size: 0.8em; + color: white; +} + +.keep-me-logged-in input[type=checkbox] { + transform: scale(0.8); + margin: 0; + margin-right: 4px; +} + +.keep-me-logged-in label { + font-weight: 300; +} + +.keep-me-logged-in input, +.keep-me-logged-in label { + display: inline-block; + margin-bottom: 0; /* I added this after I posted my reply */ + vertical-align: middle; /* Fixes any weird issues in Firefox and IE */ +} diff --git a/themes/black/client/src/css/03-errors.css b/themes/black/client/src/css/03-errors.css new file mode 100644 index 00000000..e9f97f33 --- /dev/null +++ b/themes/black/client/src/css/03-errors.css @@ -0,0 +1,12 @@ + +.error-401 .header-img { + border-radius: 0%; +} + +.error-403 .header-img { + border-radius: 0%; +} + +.error-404 .header-img { + border-radius: 0%; +} \ No newline at end of file diff --git a/themes/black/client/src/css/03-password-reset-form.css b/themes/black/client/src/css/03-password-reset-form.css new file mode 100644 index 00000000..34066bc2 --- /dev/null +++ b/themes/black/client/src/css/03-password-reset-form.css @@ -0,0 +1,4 @@ + +.password-reset-form .header-img { + border-radius: 0%; +} diff --git a/themes/black/client/src/css/03-password-reset-request.css b/themes/black/client/src/css/03-password-reset-request.css new file mode 100644 index 00000000..1a2ad4df --- /dev/null +++ b/themes/black/client/src/css/03-password-reset-request.css @@ -0,0 +1,4 @@ + +.password-reset-request .header-img { + border-radius: 0%; +} diff --git a/themes/black/client/src/css/03-totp-register.css b/themes/black/client/src/css/03-totp-register.css new file mode 100644 index 00000000..cb76720a --- /dev/null +++ b/themes/black/client/src/css/03-totp-register.css @@ -0,0 +1,22 @@ +.totp-register #secret { + background-color: white; + font-size: 0.9em; + font-weight: bold; + padding: 5px; + border: 1px solid #c7c7c7; + word-wrap: break-word; +} +.totp-register #qrcode img { + margin: 10px auto; +} +.totp-register .need-google-authenticator { + text-align: center; + margin-top: 20px; +} +.totp-register .store-badges { + margin-top: 5px; +} +.totp-register .store-badge { + width: 110px; + height: 30px; +} \ No newline at end of file diff --git a/themes/black/client/src/css/03-u2f-register.css b/themes/black/client/src/css/03-u2f-register.css new file mode 100644 index 00000000..e54cddf8 --- /dev/null +++ b/themes/black/client/src/css/03-u2f-register.css @@ -0,0 +1,5 @@ + +.u2f-register img { + display: block; + margin: 20px auto; +} \ No newline at end of file diff --git a/themes/black/client/src/img/RandomizedPattern.svg b/themes/black/client/src/img/RandomizedPattern.svg new file mode 100644 index 00000000..51afee6d --- /dev/null +++ b/themes/black/client/src/img/RandomizedPattern.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/themes/black/client/src/img/background.jpg b/themes/black/client/src/img/background.jpg new file mode 100644 index 0000000000000000000000000000000000000000..974ea273fa87adccec4b4433fc4d097c04ae4c31 GIT binary patch literal 587 zcmb7za3BG;m3jxdn?P_E@h=1|8$YR;59+9 zW`I5}XRiI4&WC@yzle_&cQR_(0pU8Ji5j{Gs2||wia~Q)aTB2CVR=3aT5jO?)niY+ P^@QfW&?n$dCIR+GQO8ew literal 0 HcmV?d00001 diff --git a/themes/black/client/src/img/icon.png b/themes/black/client/src/img/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..040d10c1ab5feaf6720fa8e8c0a0aa336402894e GIT binary patch literal 1461 zcmV;m1xosfP)e zSad^gZEa<4bO1wgWnpw>WFU8GbZ8()Nlj2!fese{00kXML_t(o!_`+?Y*bYgUHhE- zn3=ZI7qzh{%0mzcVu-|44KcBlMg%4JNF)*ye@ry-m;Ooo@C(p{@FNm6K%#tzfV8Dp z(gHC?u?eAyEkY@6v9v&$K4#|5J!kuI=XL2!XD|}uPVUd$_w2p)Ui)z_@E;5PlS6Y? zzD$W&;aqBgCCWIgr3%#B17TU=LfaFniBfPfGHk@o-q0}>pSg3elo_&jXcox?pxuirC( z+64_w<1wcpt~Ct9s1v|4#r%X~rnEyMkPy>M!hxWQY!F}#Xodt_W_^yC&$ChylYpwS z&=(^YTQB4VP`#+(neoZ!X+^?|Fo&2@Mx>OVR#uekyFNbs3W#>4vjG6i{zq#-D?lmO zgwb%&er)to`<;|MBis1xiRpQoq=1lspman6NDy#26twqNR#fgA_~q+c*8$*>20$i( zX43)A%7y?m0U*TPM9rNfXHy+Yt03Y#2NpCw4d56E zr8CNk8ARxwwFZ)MCdHT&Pdu5eCDZpn@^ zg!ri-09%OTsSF5HzMiccym0J>)_N^~%UQkOi}{T!LDU98Rq07el%z1z(&37?5ok7xHr~3h{1!EAv9uU=n1bkaDm3plKtq}^CpAB|@>l6g=Th{?L zO><|;%(Uw^S3mw@#S9R`U6(-E0l-S{0N(|L(*w#PM1vt?cfmoFzLeC?=_s+x9)RJv zx_T-a+gbFrCOycqq7T~N44pf2vuFUnckNe}Et`c$GGYn$Em5~-bAIoXqv}29Cef4~ z2yM@YUF1_#SCxNnnX)rE+6@p6Qr(*6e&3X1gC-+a8c+&BL+%*qK6K@t0rdTNNQVOU z`=%5Qz|c$<11C_oX0Z=|Z|P*x4y9laU$!iJR{F$j%3FW5_5>uoM=4&3>Oj<9vSgh% zpy=+qV9L1*!8#Ez-`$zbhb**wobmo&McKSC1NF&>%U#7=0}m|ebs1q0HD!v9 zy$h(TT*3Ty{Ax#AA^WnJjZ3BTP6{EanRzux5Qt1y^Q$1#gAm97P#~o>v3^Gnb?z_5 zc2NL$a)2xAmbJ20*BIQu3@cgwp7UhUS%QW_rq~p}dg}0f`!}4kbp4`eG`2hHsz#kW zZxFdbQ|=801KV!&9J_Phe@-7=xryR#eBI=9yqTE?O9J-xk&7+8_&>_uokeL63{>@u P00000NkvXXu0mjfXm6c7 literal 0 HcmV?d00001 diff --git a/themes/black/client/src/img/mail.png b/themes/black/client/src/img/mail.png new file mode 100644 index 0000000000000000000000000000000000000000..834bfce9107a94be10da4c011fb78e37a192888f GIT binary patch literal 3545 zcmWkxc{CJ$5dZF4tXtNx*0s3`#S$s!5_0FZ2?r0sk5=BUk&_Rfm`^vTD z%#kBkY*-}m^PBf(=FQA|pU;~&f6Pq0iSbnyMie6efJIMN%k0Dx{#Q8NiEUML>OFC= zAWc0B_=((uyT+Zw3^#Rcf&ici|6d_HOJ!{*N#0;>T(G&ndvJ(Tpc@DY36b{n^A2)x zy6GnEAL#LPLj`rhV5O&}VG%m<>)Dz)r!}gh`sIE>yJEBH0oyW)_DS6=t)VW~UUAj$ zm+hiLcfH+H{fex~j_(uClb=^=A>;#1uWMUy{+4Tb4*!BdqZuD{5fW-KrVz0}Qw(L} za@q)nnRz60-@;pCi~Hhm>IUq&0(@ai{WjCs-tr zACjZUSIC+XRz?w>hDYM}b7}IWaF45pAB@ivor$T$1Y)i(IxTkrrMHkX&kWKPRp1hg zo=mAMrPeyF;iZ{Vt?q%>H0ZzDPZKJ|(U#Jl;f z06i6&8@wn?^&SkpMos(hkQeM4T*f2MgzTaNS1mA$!umiOb5LgSwFu%u(u zxl5l!lfKwD2^vQ@s^A+-wh}8pN2y1)F~TmnSLe`z?}x~jT2_L8%H|axd=D5X`;&{> zP>X5~|M;ND8k#(ZG=gC1d}?gqKl-%2@dGXVHzKB%M(%~U@vF1V8GNgTYJRv*V-%RB zE?7#4CY`tCW6Rh=X>>G{9qOo_vpc4hVxP@Q_CSH)2MD614Seip{M{ETb1p+mL1AN4 zP=*G!s#NrkF=IRj2zEi(Am6vfJ0yS9t@)iN$CIJClYjK3=j^;zF+d;xVL2xn_C8sB zbt&j|^EUgp$dr>75LtlGxGnY7c;~jzrPG&&5M|^-kJE=CYq(H>N7h^*^;cOA<@|L7 zo3*HDm~%9f-e&kdFH^h|Q4$a%B0dOms8)v!9&#R~<$lrR33!A8q6wc?Y!XMKRwfOH z4*XY5U)3mL|0YU{TbM`kq>j85d(Y6R1%<^YK(Rdy|IFlW%_GBRvvkXm9gFbA>2zT4%~wgv zAa{%nEl}FuVcZ8qkO%y;u}g;FXX8CA0(V zHo$2?uxjL&5mzp7SH^_L2C;OiCX>rmMyoy;vTG$k2nW{n0cDEV02>9Bh=VB)tdf$W zrorSuy@_@0rUOW3$ZL-KDckegvIY&h_WoG-|1ck^9o(Uuv9{`?2a#*EvstqrcU;OkT%&O z&?2q|bdqN?hn4Vf7m}CLABr98mRx8-;?q+v5XK08(*u@sx_(3j* zwBweN+hiAHpXTP~5Ms>Mi#JY+7E~4JK=DjQpvuacJBq6F3_{QaKJQs^b}qae=K8>g zmjUqkf+}w1a*BP5n{MVHzz2hy<-_{Dc)dbjoQebl&j$rIfuF2d!KJx{Xan~57;aKY z*cp0oD@2eKqn|i>$qf2D({4472CcKI7Y@N>p{=|McnE1GMUYE&cW91q>S4?h5CYdc zD>jt_+|+-^so@x@KH>st-l0mkN2n*Xf7vo~s4j$KQNxqukdo#tn*4rG<1cq8nW0=t~jx@i5$Yzh}-9R7YT}D7A6DYU5 zB}Sommbv#GGl+$48vKt(LSc7%-B7@|WNpltJ)j^na_{1~R9U)o$SYR#Q*m!7C{I!- zq6B&H1TBRWX!)D}Qy8DT#!Dy$H9;TT!7N8x&)vOj*_r9}hi)PIT%O8qz!uuIyOIMX zmf!B#>W3K9xc`td+{ymLd&R7@QovuOEPBz^2?*eD1G^Svw3mY z--Ts5TPh5EQ{L4ab8gTT(`bvI&U?)fx@UC?;hR>-?)nV@{H=LNX&YboU+{G`mW3mT4zkU=M{34>Ue(w*XT0L=|s)EEY=NJQB zyq{(qvcLA3&T++b|4(_N8|OlznVJ6QlY% zd}XHHJ#=tl^)=sY=cnyIL1U&?XP5cYh7^8gE_ewLFyi9T36^c`=bVb~+_)H4GN63; ze!01GwqHW2(hWpeKI4|we%qg&+E-E<7bRi~BbBbko-X)$BihDeI7>Ei*3(%H9CW6?+!kwP)K}$%-iX&Fx-dO8OayE0>WtE?}Ceu*lDoW3u)c+rZ+>D*wT{+UNT4v z7y!ad%qetWFY*OwQ>w&{#d9_*L_QC`=Oxtk0(TaG=JPJtgT10hh7L^Ni=L+Dk-}j_Zb{DeRbsUtukh*#)(O2maD^ebRpzH&k zc1-{#JRWW@uk?3xYia$^u=P^Uf$z}^$Eya-26HZIyOKl{fJMO_#eP&R3VmL0WNyWL z>|hHtJ009@t0p!BZ))12BVh4c+K$V^w=fsnlpnfpM6JX!51A~t*kfGwF1j9})Q*Uo=fMgwFn^O>KYtsCNIrx$_O6?4zks$w|$aiP)S7HbUnC?4#0( z?9RyvUybw@hfr{p?rAz#dHK3NU30$NVMv4d%yC=7jQgzx)^|a$^*x7mKbVyr%BO3pDW&@U?SP)W=! zDc?Ui-aR+*QAzPrN%B%k(lRg6F)Yt8EY2<}@KQE7hu-4Yr*R{3Rwzk*2yw|}er{!*H?d|RE?(XmJ@9|Pe z@l#6iRZa2L)$&_X^xod}eRuVKcle{8_^zq=uB!R9vH9)o`n|dOzPkFrz5Mt0{o&yK z=jQ(M@&5Su|N8m=|NkefO3nZP04a1*PE!B}2pAb5A}ud5Nl;l_gqN7AtiQp*$ji&x z+SOM_51t${QmxXRol}500C!7L_t(I%XO1yLxV67h6AluQ2_@iwQ8v|?ok!@ z-YYn2>#nW&|35*)Th&V_rjo9T ze#S;A*_l5-mq!xibJ6!wKOoo|s*V1UP3zFvtN+N2dmiZIe60TG@1IwnMAb&U!p;(& z)}V2vi81~RiCPdeuW8l@`p5cH$Duxa;$!@DOgp9kjcwZ?dHnuDpI=KyB(DLJ)l%Oiwu0v z+v3DX>XcwFHCNP3#`xu`i0+p@$j}v zvupdO%QTv77cWeCo)rccc+eBSa%GrMK}}rR1TofuJ(!M(-Rha$>YCo`p5N@L;_j;B?zHCcxaje_>G8bj z^1kZw$L#db@AlE~_S5k8)$#Y&^7q~J_~G^W`|Nj5~ z|Ns9nkp&R|000qmQchC<2M8D$ElGrzn8C=}`_N%@rX)a0Z|ZgN7D!E%EzO4BF|T#Lrx_z7o7 zd?!cSUYQljo-O%8iN{bX7`_+_qS82+VrJ&JnEfw#z64H(U-54aTjihr0J@_p!#+G! zy3)t@`pEEzh@4#i^|Mv;7Key@{s%shl_<~B(S5fjCT5W<>1t;HU|V=x3WPOt0|0}0 z;R+};#==wo_zLLBoFPTVXzf+G2XtgWM5*d=5kONq(p*3b_?p!LkqWp59$A+NrCECi zE?74ypUGPQc2z*22i4BEz^)$kGw`7J#8S(ueFE+LvgEqg05q!2ySiGp5X?S+^Vhc> zR)P_dI{|=~6(ejrC)y2Qa66arabZmMn}^G$K_mOghco%~CLizL=l=r{;7UBdz#9|* O0000yM8sXh&8)@HnE5{yMH&b zi8!)}IJ|&4vWh&ugFU{3KE8xOzlA}+hC{%IMZkzm!;DhJkyFKyRK=22#*$UWlUBZ) zR>qT8#*|sVoM_0SZO)>6)2n{dtbo+6fz_^p&$W-)xRcttr{c%C=hM&c;Lz{k((vNc z@Z;k4?d0|EQchC<2M7rb5f~XBAS*37Npg6EmzbTd zzP`c1#K_v+#*M~}1NYv0PuyGb-`g~4NNM?g=#$?2rOjRL0O0aSsuqeyLz*gifKz@Yg191z zKL>l|7_uoQdqwWkkwdyK%Y$L$qHvJ!cjbnIeZ#AxJkseoWH&ZpK4mk%m=iCbf1Qqz zTu8Qxldn8tjL$}lm15K{;^3>#m}rMo%mX9`Uwbh*KI%1IB%rcG?JhFD&iHE|>={5R z(9G!4E&e^A{EhKx2w`_Z10fQfzcWIaePgxxPWjU$B|M90H0=dj*NGj+-Ny})4&FI4Kv;zI(5W!X~C zUYq=9P~`C<;j-pFNvaznhqborUbw;E+cw@2us2MiAKF!!+j5C68*eOdG_B3iQsUC~ zf}^?Ab#S<&p*YXzQAc}KVcy|29BtJBm-*)7Ur{cM|38x literal 0 HcmV?d00001 diff --git a/themes/black/client/src/img/notifications/warning.png b/themes/black/client/src/img/notifications/warning.png new file mode 100644 index 0000000000000000000000000000000000000000..ab8b54ffc0ea43e214c5b0a9ca13a8b590ded293 GIT binary patch literal 580 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dy%*9TgAsieWw;%dH0CG7CJR*yM z^m`CyyfQK10+1nD;u=vBoS#-wo>-L1P+nfHmzkGcoSayYs+V7sKKq@G6j0H$0G|-o z|NsBro}_YrzQU{d+OKD7zL>B3e6H?`c{%Ew#`y9xfr~3@Z1+ro2RJG>m8qL$S znrCV?&(>?6YtTC1sCB+c>jDrpYh7&Cy4w(iq8y3c@UF36%l#cL8k7YddH`2{nu zb4W<)dbUiTaqz^g$Dclby}H6Mnt_3l+0(@_#Nu>o@X4}620T-m3x$N|_$<_Zxi6UO z#sB(s?ca`0x@1-??YV86;6(=US2wog3cr4k!4t14H$f}^j_b{hPT^vg?4v($bVq(W zKm8B)w9gM~zwM7*D_4BvynO4A|3VhRk^5e2Y)<88Xg}kvq@@4Kp5cJsmq(o|Cbcpi zSpGnCqPI~3>jRC7B9V~nvt(klsYhDhGT*w&)XQlP>IKiN(4Ay_`qsC<)~{1nmVR!PD}COZ z(3kxnC7)%nN;&_XJ{5J>)4TcQI?JNIU2Zq7t-5^t_xGRmjOKwIbEe*~^8khwgQu&X J%Q~loCIDF$0tNs8 literal 0 HcmV?d00001 diff --git a/themes/black/client/src/img/padlock.png b/themes/black/client/src/img/padlock.png new file mode 100644 index 0000000000000000000000000000000000000000..31abbaeefcbc507ac59450a76b222d0f4e2ef298 GIT binary patch literal 3265 zcmX9>2{aV`6aVgk-7GeD>sK}rA(Ry*>^gE3Epmj|b<4VQ&fYA4`JLL~x$GyxygiPF4+|0N z>#b^bO(w)_&M)^CaxMsCWK&r#aHm&lGhQsda2g)u)YZ+nA*$Y=^SD?-yyum$c%Fo+ zu$ZJykg9=Rm)m9Enp(-yxPTR<#xXC42HEHB_4DZg4O#W`_q$H2B2I8p9;H-yavPzH zq>PRmoiMumG_oM7?b?9OKx=_Dkk+mM`wBnshtOJ>eU=7mz?U3Q~$Mj}SCJX1OL zyqf1?ymH|9kdKx=DX?)8JOf!9^UJ%poV#?*v99*RQ2i$@z$~vES|EhDv3k?p4arGu`0~c! zB)j#@7}HKFW*v0jh^7s5seY+tT~QL61vGi3^n&;$V?sqb^m$mcJ2D+&AnT5%LkI5`dy*61((J>5;-?QSiAbncNza z=_&RvR%{Lm(ptkSN4tVOmF{n3?6j5{zR&sjwxo5xQ|}d}l%r)b#65Pmb-wHy%d{L5 zwPQ+6{)w~M9AWrc-NtIJ$ENRvF)A{Jcr?4>y9&B4!~eLW4p`>> zhOsvg0z;G;#Cd?1bqhHtaA;*Rw-MY>E-4btGR_itUOXXU;(CuwOo62{(s@)KAuvtf z94FvGsk{$ylrmS~owI-KK@53|%_`yM&Ga8O>wLP){j(dkJHc<&j@laS3U@w@+G6&w zEevGlg6D?SCiHJ!F%BgKr*L>vX>8T2Yz8_^rTT41_xEvRdH3@!{Y6nDd=hKj!2QFr~YXu#zGc(XeTQN|WFn zQL_-O7SLo_0!Z!Uw1xG0znZF6-!;c4eL1j}u0$=vD0_f6MZe}${`8%K>BVi{5{i64 z8|m|u%LIcFxg8~xj5`zYvL?g-%vn?irwONS$jMqAk3f>u{z3D7%BMV>yDEvMtTO}d zihwHDkS-^BS)X^O)qI+wO7Cc(2D*}y<0#8~eDLL6y<=>IF)Ap{9VFsh4qS?SFhufl z(BAyj_Xz^v|A{MSeU=T;7-%0JP+Fk$q2Jf_V0xC09?A!2-q?Aq)ofD99`8HUCp^82<@{syV+N=MB$R(;352@)bTYSwwhc~HL>YeOhXS2 z7`6_89MT2B;X|FdD&I|?onT7-6g&9z@)L`P1X{VjD@l`xFt`dSWnu-|L(T!BuDGCO zoNZT=%_xMbko*hlYL}Lr*taSNRW1F@1&kj9i@TIdC=fj&0N@eyB`7fWtiKMWbATr7 zg2A&7Cg4T5(+4hN01qd{VyFg4sxm;p3HcbRj)$mUv5*IoG^Zgs!BAG-W%*=6t-UX1 zU4QHH#qWz97EB%r^T_V%r{|qM{wSWG<4a_D^Ew7a$i-0Yk9WjL_=N`g?oYJ(W=ebM z|GFOF|0d|pVM$+nV+MucRs3I=LV87{<7gYRlcx$_ud;|+Mjjfc2%;Bv>51`;2WwAO z{c#Xh5c{q-09~!XFT$9OGxf4;JiF(&p>h>ky0Ibgx8JJ};Iy=w>Y;6~e>xfhJR=#) z?X7~{(N*kOUXV%aks!{hW#9|2?9tcRK#m>F`IY_s5%sSFibr9p&|^RqvfLL<+0hfZ zQ2#zFFl5GcWlkcj8mkvA&?_^zI}S)_xCX$VFb1XnngbM-h5}-I!jWl0to`{s>4a!>rl_SvhYH` z#z%W=pEP5bHRfR-e>gHKrz_RgoAR0_lc8 z1wgm>6)?zy0O_Y4nZOkhU=5=m57R>ePM|>OJ{JSjfjgDPqai|s(%GPC-5q!&@I~B> zSVkm*1!$!dLJMNhmHgZ1#hYDkem|p7SVlnEVB!7mzhr7(ZDwave%rx4k2Hgd2yY zu*oy(^w{+B6AuMNhcISd%EV%^s3qi>3E{XaHr<;vT zLLrQgWh590jnIhuyr>83`CRZp^K;+SWri)#8qXn&eeR2GDV6$hV3 zC;|yg5QWXs$In0}S(;{3+Q)f=6U2}}MMHFMPv@82v>(iUde7*h*H5ztB$Znr8fO>y z!y`qZhxbu(c4#2?(|Dsu>wHrr5L8Mm7v}nG-NOl%x7?KV{2zXuG5c zINn@IL5?joMBh>QMAAEjtiT~pmik?HjwA;M0d{)zA}fX%gcBdHORe)+lk^9Zr_Sa` zqG{W1mf=ek9)Nz+@^^gU;jecyJU>{AcJz_GD5nPjG8kaCTN}IbF=6ffiih+nz7Y=} zg(Wv$gUXyf=OEH{vqj2}e8wO2W(9OkmWPm8m*O-uDNz}#qhmDS>q!LhLM6GcT7m~%ocUNs)9JZz20hA~qKL^0J>S_J*M z)nRkU{h6XFfO=E#6g^4IIX9ymv=`Tp0?dRuu?g;HXq3gz7`?Bzmy1d)VRWn*uU)mN zYWD;+?tYeZ=I>*4nFQYCc=YDtY9sq84;X$x+%XA_Fj)!tqt%uuX0^Y0;;2xn*cJp` zv=$LqqjK7*r)2+UuQcF?iwJcHvD+aWYZ@o41Yr=8*nku=w5P9*7f0p$0HdYq1mjgn zqcU#ytbI(erQh)M!$DN=c zob)aNpOrt>_~Jc-Tol-c0s~2SUY7LD?svNDNk)x9cNst4vPu@PS+VaFOyBnbK1YM?0W`%*k-$2d-mZTsWZ}_GVrOLYb{)xfkykLOnSth0t7LBH#Q4w#lnEDYfbqtK!^A6}Be(TLV z()-%kH^MOnA$DAl#6;0q;(8P`@0<463$}+n65fZVc3~e`j;{ez1xa zR7M72GnlI3{adw^n53_0n$*%)F3AZFNit>LsO49xGM6sRglr-`t*zImB_($s9aRZ^ z&0Sep_H>jCoh9EvJK07LYM$|kb zpMKcaMs8yX1!vnOQ3S1L+uNGR(kMc|g8X>h+?Bjl?4OzwFoO6T89rbg>H_FjBFwpG6+EE zN%ri5OinH4dsL@S;9}~71zhj3Y|b1D%iMp!(}3tW*_M4A1FjC`Qt+|9(rrsBs=M=0 z=P+YB005w8wBBy`qm+sncXSHNI0pDs9p%c?uqAMYf5Av zN{`(i^Dk2lRfB2kc(bs=xZ_}>O$S@>*IfzJ9Y@A!D>DLplnl#flvd9#dW19lhV@6I zg7(;x6Y1R@esAAx9~EFH;#6!88D3Eo6OPeuc-V6VT`DY^)HSJcR#NXt4T&eU4}~d; z0Fn1_5&O5cWUe_WJTSb1w%n5aNVNNEyPOYMZwL;2F;NPSZX9Zi3L)HWibmQLj+Q(y z0|vJdKi|%eB4*b1?5dt^k4}gfY_i!2PY>}oezLHK-qSK`o|;jK9wVpcG5}UeqKKKV zwAkJnxh$?{^^vB_=9Cg$q-j=1o0^9CBc7Z^iN${{7~#b*!f)!zH+Xc-o)P}&iG3)h zkER+>l~ovZnn`*;i)yw=^L|J6U>XclESv1t-E7u9rt|iD(?qErP8e0iHuK3F21!Gg zhSkOKt|Ciw5UxfwXHkKcRXrWPvM8IVz|T`6$E^b`eY&tCdfJh>$AhoHLQwt+$EpO! z6qjCaD;a!aRH(InPN0EbSzqnEh(f9~=_5zh?iXvux`#GflkhLWzmsNU2WbVYg=#98 zR!{FzrukEN33nzgVQe%2RGq^S`$UjYG&sDS3At2f#P-eX#vVUSAf)_QU z3AI#$B!`)QqMm}ttH8SbL16sA2YJYMtCP)Da0RI2KJmKf zk?bV%RwA>;1o3;{QWk*Yswqj~UyA@R)(pI><)rc0%1zMg`1JYp+ADycv0%6eUnjOD zpjRCuzug>@^hTa0cD17lC_BcE5mulOf^dB%HKV3>ZL_UJ!y`6fZM~6IY6LTnDTSE6ZpA->X_J`@mnH*#(6w=9ulM zF&}ZjdH}YkBZz5=8aNVG!>SYZx{?&xY{Y5SBIzQ8Jn5Nse8K9>=9DBj9!N*te{{wzHdE1LHD0SFMO8uS$w15z|ff$5W~VZ?dA~#x&f7)3^VDFqjU?~x3iTCKPY)su;gfIrWzWt zmY;zU#mjxz_8jIeVK~BRY(i+UNsTK?pd8>KX~aDYM~A@lfk1sd8INvqz#p1*VP|sv z6{M(1n_N`2!2u@_8gOib+#6(VAGC)e3I=RW zy}poa@S*3i@zaZFH*z?V0 zZB}+k$~M2@GE?x}J0CZ#NKAbymHTw~w|u}b{vJJEAO7WH_=tFXT7iR0Tr8Szk{iak z$#SA6*7Sa6t;@q0y>b)E5d$d|Y@c|VrT;+LRFZB%&-YNz(ku(a|0Mrqk-fk>JIhVR zRW*>HMqwwFSuf@BH?oBqO0u%qq?+xL zi?KbyZN#e+D$Y>mt9S!g^7{m&_nicl1+?{!5skjq?T+645K*SH6W7{CS5^(RRnWnJ zu-*}(r+t`J;^h+-mC7F7R1k1?_bN&-EFkF>ubQO5tN%<1+ttb(nP5_1*QDPi2e`EL zEY70`;CH@E1Q`l?p#JQ!Dizgg)l~aAB63}`WX4QuUXBYWWK@=qOm)-gp!NSrUD>wb z`&5h{PU$g14}q&Z#goj779|-*)vx2-ytB*DAF56zyo3Lg!}3V-x3njX{{-`3ElULW z4KL!Oa3qmcYDl_gZ`0rUNg!6{^QlIS(&$J;khg@ueo|3ere~{xQ{{SF0z)o108z*m z{!O|{d1(bNr;2gj>s)J*%~)y|+NiC2HG*A#Q$rRplR)6YbyXt`LXG3a1fSHnhzB|oJlDT;8qDTuHM*ux? zEq^i5^@aY6=L$#|Y;4LWE}vd!^UJ;K3~TgGOWfauWzGz;@6&Wh^s$OJ^To6|yG`g% zHcEO>7uZsP*xQp7Bi&H%n@8`6cRABq`2ZhDBQbqWlr(m{j%lrB#Lk0=~ac5t)t|Z>Q4jH30;8kH$qkz)OmxP^z%n# zHVReRmb=7d0aq%{>^6wnX?C|2k+Z8VMX6f`ns9SSpP2GrWZN74=-djRJqLuJ zdd_1KD)m+WOq5k&&9B}mo1DH7NnghlGHbT~Hfc>?%q6t4LcQ&Hs zGz;n#p~(4*d4EDN0TT#v2`HsD;(#aLZ@Ao=;K*I~*WgWr2^$b{uxMu7VqfUn7muy= zlPs##Z`6iXy`8JsFe~?!K$A2LjiiX$X<`?&_7kCKLkUNvE4|y;1MwnUBn-lBClYYL`wT;ySc6LAr+HJ0qIS$BW zW+9q1)lG1I;~@K@BKI2vV_^^yK1#fVh>(pEETGjZ0h5 zhbF0{A1<-AOhG4(N%nV*a)MmydUw{{(@K}+fhrB^Tt2tp_UBh5gL}Gh$ zWNynbo30F7KSq=my*=9TWcV*rHl+TnD~T(}88f8Z5<}^xx^e(b=GagHMkWG-mf4F( z;=Zn(lqU&U4zZ#AMOyo~*yZ$30NTH8=#T?=0adgYV$f+GWnEMOcz;bfA=rVQD>qZg yfK{G;*sMIT^q}!}G=q*C=Lfs;R>;VR z$X=mHWZd7o&-e4k@4jC5^*qlx=Q+gJU`;0Wjzk14Kr2 zu=aZFj1G8jZ37D;`U@gDMxbk^E62`y1F$Uo{bPtXm}F7lP9NPfKIWI5ef;dboWRe| zPsZ(%ySIb=6(^a?UM^`1YJw^rtlsTWbmwuD!;DB%b?}U>BPr*-HV6K#5krRGRm-9 z$5_1ZA<|iRr?ly@qMTxS>fxa+^Is{+QLIMv;nu^Cs@f0Sh(FC4!V~br!kfBEZX+*{ zHOSF!r@C}H-*t$0jdvz?rRP)_dEd^zHNn17;yph+{<1yNp8ny&YUrP$!f&m$&faQ- zAc7`7BkRo?vc6c z0<-nnd#{u&*2GL*ml!r6UHJCpQ)_R~1i&ocb>33(%f-Sy%Dq-rJL0 z%@@6di!&emRP?wh&j9{X!HRK`*zEHZLr1WJJNeDrs=LCzEX`f>yHz_vejtT^zF*1RtrrIo!F_j4Ef`}>UWp5OND z%&DkR;*N>KSKkW|;8xV4j<=vLmqxSq{0(^y$l#;1`Hm7N;tw=?Z!xr_Q!{J!c!R9kXI2heg$7akcGqZIWikBl#xb5=fi-~bD7xU63(VmHW@61}0@z^5Y`d16 zBInALloRu$7ARjPII^BJ*y$eRb@E^U_|8YaN@Zm=eRtgK{lFYhgeJ;9oU)FHE!|r} zfYO>$5hL^4^$~qiS3OvjaPV20UZB!)$8~;xeF4w{l$#E|&pJv@J}-PZs(Gw)=s2sA zPR^6@i;?ms6N?dZ8$`)vh9g+G+ASBjX`4Z01G@W`zM9BtUsz|_0Ybmz_%Bg z=-&eGg^sBfIJf^!QW$D$Wdic^O&KzW)3u4U$=<+sR1wX=N`n_e<(GQW*S3YYy(%gE z_{8ElEVLe|+wsvM_3M#lp!_JanT4r&~g{&YS{q zoh-`(%lKCcZ#fcz#0ezBU1vxjW>WtG=haEQjXe%SF$6>DFzU1DzlGE~4( zhMMsykhrj~KbP;wKD5voof-m^e?VNDGSlX+hC}AIfF=sZZcvz0po6dwei8?USd?DF z8bcAea+)fbC7bFc3Mv1O3bfLKYzIy`R?J_naT0r;zKiwfp@Gay9Sf*+V7c* zFzh}-vJ3Hgj)g7lU{lAgk~ItYowK!JeRr&%r26<#O}>VFuri-fzQmQvE5wFxH574& z_*>d({ACwCO>Dlswg`ES&B{&75%ILC6eix!AV*^T+b)+ z+%)H219+3$`~Bphr&tR8ZE4>`@2kE$mP}xA zymwFSeM!4NGH)*S;Auj6N*b6Cac;GnYM3m?&DHM|+AIG8CJIB;#h`qTK@PBJ3#@mS zbLe-Z`A#?5`60`T(P%@U>z1*J(_S# zYgCtd=6Vgx`gd}#HC+PIUhMG?Rbm-7k=V4*%db{Np7C;ilZvDb= zM*NJzAU~sQx8`u2efkcd){^(Mz_C6`9Vol-Sd<|4=T8ob-h$it`P;=HRS`1A7^$Al z0q-azOA(NelC1-k7-k6buuY}g$@RIp zxu&Fo-lin?-o{@dm+s%7HOaQ&pq8ur*DhG$u*@mn#9qlAG0Zt0x;RA_TbFF(smm*N z?C2|+n#7<1LJ{?Ql+e69EO?VvKduclW$g2-nCaW2KbdH=jnDL==|%Z*2U!i}0z^$| zKV45=t@+`N-BW-XzK*!k#X#e0nBYLd9JCFLFP;mZpwEt8Rp@q#ZBoQ+93jL%Y_Ij; z414O{YoM$z0yIhNi%NK=?YWL4sXzLrxP3Qs^3b*YML+iscO1T6bK46bllZKYfyC4> z!GdJ-^v3Jo5f(ZadbUBg=QDO!lV(3nHB9JT+hI;vx=pz!11@IPwGd&rPx;LDuyA+0 zmctth9HnC3uVR*}g-=Yl4TvhfO#_2`$1K5LmUGIBJMkoymH62Lj7#esIe#hBYulnQ z!hy+K{>TRdIj@2+a{hx)C3}DT3J%)(&K50AkWspF(ljjlgQ+IaM6loaAl=Ne7Kp?5 z?3a2%TQaPpFYwI+DcYD}K}y;d#B(zhh_t?$H0jDc5!r8%-STGv(ZmlH zv&h{B-W1yotL@6l5B$Si)165L0hnVSWf)*{&qxjw3FM)oE)P%Ju6R#&N{K~Nb8QZN-qYz|vO-mLsyEF+fxTnYxL*hnRK;E<^ZiEGB zV%WRPATK42hRR7%&K!u3>BOROB7tpUhKm2d@daQS_aAtI8Kz7vQR-Zsk#sE03C-I8 z`?$)V!4*Ot+WAn9;}@i$JL?q+J{Cz~LkFPP;4fSu(zswT#`dVQOq8S>!|>hmoZ)-8 zvpX_dYFAjqMOR;aI$Qi22|9mX>H7gmEB5pNnJ=RU8owsWHtH{zCfF=8U?GIFJUjtO z2RZd==uh>J(~$_yvwV|tBEI8+?$f;^doMdS+(?yZY#^xd&ohAF$7c?=UpO4)WWyDC z1v32x{PQ*d!C05C-wKN*?%%=?hs~PRp?>ZqAbMl}#0PPwUH@xlx9aGVckHaLphgmp zU5tzo7!<&xa|1f9D^sM-66_tD_>qy*9k{+BKW#)5JZ@ zSk%{1(0J3zGu-RrzcVrMH_%!Oq#p7};J-07ff-2%&AsPoK6x+ha*gp3-&yU-Rbfj0 z#&aBSJNce?(_0DU56it^q|8TkW+;>s?WQP4s(Vsjg)TprT+giAdcZ2Ax*Lw&t5`RP ztb~=Lo(%-M?=P^OU$))WZSf;#of$7O0=MJWAH1RG08J~SIJuABO^XxCcuJktTMWVr zx!d3UKmc22YEz8y4^6kqfz2AL%Rt)ZLq4`oH2h{j=|ek`N7E@XzzPyb>F za`=~KcBnb=Ux_QOo9aVtbwmm$^WWN3o|=$yW`Gma&FqRXeX0JRM=VArth_PEv^m0Z zZ%YVE=@%N2Ieu{UeAZnaH5+VJU?%1at9ZRIpiZ;x!3no0%*PKZf#GLi(O?4WHFo5Q zO*X2JP}pEd@!}t#$<%Yt6N;3cB7JOvdW>vB`q($4MIs0*55jDIG8U7icszzp3lM|{ zIpG+0ffsJkjQ=f^o}-@QBVxhlP;0CGuZp_R=}yd0V&;e{a)U~*8{&n5Pior#)?a6q zr8qqtjq|^9Jo zkA?3=_dTW*qx%9Al;Y1BOjY1W9;caR==IuQSK?0pQ771S(E+ohhms)NWyqFe?>Fy3 zD7tW3ey6f~$pw^A{)a~@(kF~zptCz`uoOdS>WG|c{GHu9bcua z{wKB<>n3XrgTl%T;F5SQ)LNsT4J_ius^t=gme@3}KWB%dBV=Z}mvz}LJnVPzZ#sxIxN~XFm=e zHO34vL006>;`3t7bf2`v3-p)ICaDBuPX}KWv1A=`0O?Pm-&(upwy%79F}cH5N8>3a+)j#c~M@;4^; zel2p;mu926lT>dB?fg!N{onTUckx@K|Hm5j4}$C;8E|VoSA5#oXshz=Kh1<3DM+ znhU?B4KnR#LM(ToSY&Q`z=q%Yu|sIAo8ViMiwf&|;{7topfa z4aZU83gzvkeJ7Snoh6?$qiUR)^uve+l>t2a(-k^s8=vm}U1I=?I4iVsm^u^KGs*>_ zI^JfHU{Eu6$0g*Rm;a!VKPpXOCvQs><=8c@opO75p~y#M)0`9_*VIyk;5z=_xN# z0`rF>TNg^_vy9Qe@yV@_{%%meZk5Ymw=|#hb73}Zr`4j>J&nQ7TP3JRhS`vNI@`F@ zEA@`RpIDB&0n|c2vjHP%9}^y!9(0<{PCU)nmcsLBW-b>)Ym>NPWc$3!@-t`Z3(?Uc~|8Bkcn?o$f%jiPGZuY^-4TJSAHUDY;9WdQ0P%W;CJ^udUzzN36 z8*YrqSFs)O#c85>vKm#vy<%txeX3rG5(1yk>=xLtM>|D|AEc5+T0ICh@|QYd+H87 zjlmioUFGWVML!nJ_6-iTR_3ngm-6Eh|tbr$7LQ8W2Lkt z`-_$g-k>v$M-(nx#CvAze%M+ws&KJ=zsG8RTudEgJ;;^}c+R;O6PjnHY^f7%fuZFD zic8B6RuXc?g{APQ=AQ?R^CWG*8!&QHm9qzndFPepCoGd&r?ewaQ5%!xE&Ilh0%q5| z+Q%vw45D@BqOYLNmZZW!B<-)6sGImFezP><92Yn2CI-1)K;ZmjLG8=?Q(29>xdhRO zI89X`A@}bcu88ytKQY0|S0nNUm!;@J3!juEA>>jk+2Ec#qw512AjhgDBskIZoPdqU zP+hTpII$>hE}4s^5U{FLMGDv-Seu>X7ni{jkOeEWJjs6DjOc3*_7h9HbqPLknhlngQJ`( zU5QxCx4p}o3U%`ln(tLWj{kZ+xq^Z$wl>BUPap7J)kS@R>X(CT!*`VYVjv zWIH}tg9jL^D!TOP|+c&KR|ifYSVbz>1=5IeJrRsbi_F;8LNFXCfQvvi+lO~bKPXh?#Q(@ zk0x7rUqBv{e4MNJ_I*hnO5(#z_=ik>T2sxqc6)h>FTXtMRsUY}J9I((E-q>({gFu%7jo0_>I8H0YfRO8S#q;8YL$e+j(RPv7q;Gw3T&=i! zpzd(R`zonJfnR@gi~qS$dw6NQxMI&EmZT@2RGg>O=p0T9x)1t)$FZZ`3K-~~(0QS4 Hcm4kWRQYbi literal 0 HcmV?d00001 diff --git a/themes/black/client/src/img/sharingan.png b/themes/black/client/src/img/sharingan.png new file mode 100644 index 0000000000000000000000000000000000000000..526787d3be6a78d936b6a21941291994d0eca38e GIT binary patch literal 9213 zcmW++Wk3{98$U`)y1NcIN~F8<=n#-b8bl-|rMtUB8tLvhq`OlZq(MqL-u=HH_Gb6P z%|5d;^E|(pa5WV<3^Yyph0zz*L4P3+5axMWH};j z5QrKCm6rJ6o^_h-;SILPKJV80o_h#h6JSjI95ZHqMxv7Zh-;v>h(LgpB!jc46N7ae zQ3d(2%^Z+ljZ)XP9dX9;V;dBK@^%R3Fs8JpWSPd2Sf6-yo!xcxTrk6NSasB%=;tk# z`!ZHo)>hV*V=OZH_udezWBhl=xY)a2-W%eS zVv{j~=TYhNpxyHX;~Go1uiO1U_fnzT4&qDZmk~CGK*MN&_cf3NiT0 z`FZ&fDF~<+eYA$3LN!45#w}&ujU~{>}wAp%2}NZ$r$W5kCUg+XxFrb_JxiG2vMDtI{6$xsWgvd zMj=o8>A@OMIDRU3;$?zNiAt5pRH3#OTXiAA%BFzk+H+u4W$EjyCie7w>rNdm%r}&e zJUQV+m{g;7?VV-vbNtT+v9DJiTVdD z)}8qGp}!pSo_woHdJh`Dd-;FSX18vRB`fWHe_$yb2TO0qm=-N38 z(y#GyOr$=3+X*kfxykgu)hOQjVbFbo0IQB8NY!QK&1iMLCne0}ocE{O!k&zYy`1Ue z|NXqKVOUM@DSY`CeHFty2VS+J(Ss#sNn;2Ms+A_!ThITk+GdKng>aL4;vRC=gft1lPRU~ zkRl=?YJ3vLUH=&6Ka7$%EWN;3Cb3B|-)OMBI-n3)nIAAkg?e${DRiJi6hIdD%q*ih zvX&YDeI;|ojhiHDwcQB;m|#j0l1-0TFO~LNhQN6H@@xV^Aqq=$3{Se~H+03q+|ND3 z?+d0O?eKWlcZ^E}=S-eAh?`@iL^|w8KYFutP^8q!BZKa)&lVj9@x30Qn@S2b#Dpv$ zcn5Z;rG*M-Vww2cd7gS#(^JJ>o#Q?2W!YKVrFp|9%$eIMabV04c;REm ztCKrOXCAELnG|t-$9D55CFU@%>c33YVmWynlw^e*>aPizaZ@|de84})!Erazrh066B6H+R1#)yhWfjdSWIu54cmmj%^ zQ+VeWS%c$f<(+n&{?sU0YFgujv1DJ1^W+g#Kt3T^r3pmADB^tQ&U-b-i)ipVyv-Ml z^lJ@iW9VgDvmCbEz*PLno>#d+evTT*ci-mVjm6BzL0niV8*zGlquz@a89jbRQ*>xc zhb<8!JP|30xS2WxO?8&6?-^P%Ja%s`-%=)E+?*q8X{kburmmS4lTb=n+PkV@+1vrk zDeHy=2?oYna}Ixdm40{!=RxdFOgy@8aKAnt3>zO)Uwl=OV{@ziI7nNN&*#xYhXPM_ zGM}|ux;2l=-X)#&GW=dO8l!$iPbOP}hxy%CZQXMB^)2gERZ(~c0;d6${*9M%qQ3 zIqJCbb`Z`xtpxCDEre8J^CP&>oOuC60rO4lWlcOjk~N{45_C3;R#B}~Q8mucs=%+Z zw*8Zl5`$EEkQ@Mp3UhM8d?}BzRp{Gcx|56*xQYzn3eXri+Mq;Kpn}P};Ht7J zNG6~#;JsCr4FluJ<0>gYc?zbk>HXd4aF&mAJn!2*`Bm;;D&R1S+?_r_HUzZef3@OP#Do$`im7WoI8B$V_LCZ z`Ofsel8KB_%C|@@`ASj}qQG7Q_Irt-t%pvdv(ff#Ksdi@Z1iP|S6KE-ctjslN<7pA zb*j2S^K`h8ri5)W!oMy5XY%B`voZcVA_Td+p6NjPHH%sRTxA(*4(jr}n@{U?s%78Y z*@}nDAthso6REc{dLKcH^M~jn5=NmQG>5qPZ8rd8)*8wG)zFJ`Tvl_Sr`9~-^G(OZ zgm{RBt%?m#M(fZ#?0(`6>8Obv+=(I*NE z@Q$&+iE)BF?Umwq)RxCfZDEU~v*!mH$Z^m)$q!>UP~N%C1ntzbWxJ$RmJzufTpu9C zr!owncmAzQLc>jS>Qj$@8xNKwln4(varG081 z$=8cPO->Cu;4jY4YRxW*k zjd|FaUo2gGl)Do0xz{e~&}^ZXfgY6XQ6k1?+%13JvhJKca^?PI8o|TI%s$@l^aZu) zk-dP=v4cQ^vS@8++gxdbvB?0>P^qMa>>tt|a&L*m7UY{kfI zXhi3lYHDHVSy((jU1J==J7_<^0b60IF}O%NJODOMVMx63YhIL)_9w5~;WaDY?1m)X1?n-< z0d}{_?}@W@et(A+GUuZO!1TNb+5<@#Jkjg0g+-pTNrZ?B)K=Y%uyxWShfttLZTtR~ZZ%9b?y3mz1nnCEi7>@wpZK8&MUm08S&&a-UQ;v`S19mK@;5981)NJ^iH#gz;=A;t=(R9RQA?P{F-vu zh=e5LtF?AK#@2>q#cEoJaLfA|V?0(qB@$l?6xgjIj566Zsfqz1i~xS}pLxYSgktbHzi zmCCxzV4j+Y`tXttAGfu~LXW*84tY=U;8Ir*Ql9aa6b7^?~996AtKDL19Or!L-D+{ zh#aCFZ7G<149ug!LQBhm^PVIfiuccg$Pi8scukZ581fO3qjq$8#lCVL0b#)({vdk9GY7P+nr31yVI%oZHox22 zA;1l|`g|-<%e-Tigfu?aS-!L!Da#(_?R?1m&WUGYW&Kmr52hcJ9ATy*owCEOVluK= zo6S{w7#`vDA>I>(cI>TMYvBGkU%&?Y&# zJwt{zLaUd*`P20$R^aSR3a+2vOtBwnP!tEfF&Jv<&3xG?BY}n_0g$RwCt`PXogwqY z&WwJ72{10Azv58_nkUzM>B3P<$!7{iZHo_!>BR*giVCey9o5rha|<(>-)@R-tX6~7 z=?F!hq3}6Oa1KXFUO1naFRqGj{#fXq|BWJD%-Y3oAQ`tE(OL&k=u?ZA)C4x~XE=J7 zWKU2ab-t!bUAq0>x%Ic|T#h$gHN7al~ z_*uD+%(6i6#f9@TcYTFPa`DpPg%Ek;YQ3pQqsNi9tg;V#uo1Ry88t{xLXD+cY;FB# zCrMzZEVcsa;fuZK_+Qq~Uog)`ug2vE%jWl0>U%L9F^}IMUtBUcF>W zOomRC(0Bc69)!+FZ)222&085Glv24pvbWHY+$+y1P(ouZp#7LO0FWtwV74^W7D14B zHVD-+q4Gyx_@5CpCi&^~9XZokBSH9dvU(D`87V4Vjdnau!bgaAI~)Pd#G`yka$Osy z1>ukFe0u~+<+7Zya#n;qWePS131LiDY|1)aN~q$pn_du_5(rTsY(qpdUiw1xAD6Ac zllMP{w#u7|L1l(S@|=SFB+z5POU0vV^|#Ar8M%^4D05c&(R{S~!ThT*$3+HcYIX8TS_>|9~AX?TOXBcwm=1 z$oY@tkah_>;ZBrm89VSa+D5?7Mkja{7aYp<(KA((sYwRDr@;6SW2YNfy7kax;mtJ~ zcbD>H*(I`ww@AWW$#t{%xr_JOMVN{gXkdm zIK<(98lP<51W+sSeGr%}o2YQS80BHC8_qdA2T1P)pQ=%CZ3F`o^vU);-B^SxFEUn@ zsK2HH@!#2t!5xA61mF)Ar#7;4i)3k4-08R=EHqDX(UBNZCe;tE=(2poQBNlQf-kO) zj1Syg`>GFE9J+TiXC7BGeRmakvRr@s9=0M}Eq*@nB=&BZOrRbAt)4vJoTOEJR93uD zqJM9O%~sSVGxE`i`eT=%BolW4Y%o*L!(?7o&x+sAQwq+<=PAteNav@!8Aq3Mi(8wb zvVe1y&0)pRljvebuOXqn%9UHn%_z1XzZ2R8D`x7{_hx0DF%xdiQxaCbQC42j1#Y3H z&3ynE9lM7pXyQ(rCsb3da(&JlL^s5+t57?EgMA~A;p(@Dj&wdEW$vx+i=scaim;ar z4qZ3-anQ_U!LBNgE0RvYHY(fpO3eUWb#lO(jEYSuVg%%y|38#itd=Qvjz|cn@ABlf zfn3s9v(o7Vp$TBmWUmcS-30O&p~p-sx^}e-VdmH@!>mGa6icfm-7uK%_H>&l zP%bA$MClu$gg+b!NiyS*{6fB>Wwa_Of~>+*u>^b;CD$-}Y{d)pz9h zm_yRlMmWbKnJeuDdH;G$ew^kFHULmS{`dc)biSs}U3(P#{e3;aGthyyClEodrNfG> z`ln0(yo~3&tF6NeC4a@*72Ex+l~o!2M@cxItGkCSSo9QEyKcZY%`(;bm!$Yj|p$y zAP66m{6W2SQ0Xkvn3ju|maH9w)4lJ^`=V>)6HMt!ub~C%5XW)~j_am6y;VsKW=Y~{ zDm(1Q$Z^2Zkp@Xiz>2kqKyi&Zz&pO(F=EH}mKX?ky zFM5pcYw3SFA=&-pv(RdFNsC6;3}1y;d`qjyL84fk>;$Rby|U4mU~Ub-2Ss^ z^`}WILzcBuiQXUd45!0b*n#}CD+>VfzrZgdlcyiSW0Nua1``ZlxdVQr zk!qo4U<|zFbE!qdbF>$zVeB=%tsUt1{y|fqr>gQF5hyo!VRiUm6S~5F&l8siefoCi zuFhasu07gnvo~t|mb{2h&d!j=C1Y-MDJAinrcIw3@6}cj?u~191om3C!=qiez z#v7G9K+fJC-A=xeBv#_Q#ESMU_TPg2--`CLCtAio&o_)oVadRfGix3xXlnRLYRFks% z-9t}jeE<);3$MfDjFt?$P`8JRJlnNBygT)8H)q2OGKe|G+M;im1J1YS*|O{ifxm8) zXDrL&g*ZX^ohkR*GslW`0(Q3^tv}uNS!GTOqS?G#>&p)YZU@bt?8LS5d_W%BB??vL zUB9C81DMuPknas~zE*1Ew;JTB%@ro?7C(!euyQz+rj6(Y*R%!UU#E8L7&QA1Hq1f; zVai`di$|si#LC^ROLHauEOk>m-R346=%o1RtCjNI84qIZXk!U!|M9l!+!hv0QgT@9 zqBt4CK~IfVKo{q*aHfa;r^+u*T6?$4j=Hrk;PcF94vT}w*pczA+^E^Ste+fBBA{#2_^Hgq|434?xxWZ}dqcJT z8Tm#j)SaC}zr|O}Mj()SP|xSlp*mynE278U1XNU2QPN02xFGlbYAH;^@FA;XR(*7s zS-Z-FwkP`0?fm@wA|QCQV!EzaveQqqgnbB6m@?97=Y^7z-^ab}v7>K(=y{E7v<`c3 zZ<^*MrIlMLLdd^k;J|y{lHEz&pKHbq_~ahV<#|6Mv;(N8EE+E!<9iNG6S$zDNv}cT zXl1|xFjQOZ}!?EN`uOIN5y@GcawB!TvTru z>7MkBV^`n}MPT#dMjqdT$1;r&-XW4&+P|g8dn-qGSw@RQ{JzZ9(=@>|pC0yN;%7A= zcUZRs)PzgHZgqT*mbbGbE}| z1NAQY%7>h6_QL2N%SCr42IzM%umi}0WJY(xi;ldnc%HC=zPf4o{y5F^Ex8{brCGff zim&hl^Jjt(@~>*i+Jk^-9*JqCvjf2HvQ|*yFk&~U@WA-@_7crOG7Q;RkQ?v( zBP|o0Ukpx5u##eA>!w5!Ao&B5#N+6J)kpiLOCYC^h^T?%H>cy_?9YCW&+ZgR7l8qD zizV}Siz}2bn;!qSwrS+xxLfS?Pmyw;-Q*rLg~N9PE;qmCf-Nz^P{A*I6g#Uc4*|56 z#~8A>35BG-Vu=o%Jy+`-FT0eoj1dhPV-aSy8VPsx3f8V11{#apqddQXBNOJ8Jwx{~q1S*i3QY-XYx(d9IuG*OM&;NPgb} zJRz@kKKzCWHP-aqmCow=PI0L?06x;{ApCXYnX#`^LbeUTb$sXk@4l|`?qc>V8W51| z{>5CSdBst@gxmOrN#J|LmdD5PG}V+!=GmA{Lv&7k=zE>{gM&JzO14*Et&k$$2kdh( z$TcmQ9p>4QUSzFbde4-3(5P3_7E>KWrhRyfWd0qmmR9{Q)BSSfn;IDb-YRP8-CyI# zlJ&_|df7RV0KriE?!4~$8qq@)d({ersq7`a!`$3v7R zgMn2o7L3K$P&1;fRqImY;(bBPceXKHeiY$|()xQtd2$3-XFlKtyrvdKa;Kv(?7oyg~8>*Q?d>I8{rAN z2mH9(geFezLXF?c~&7U^Mu@4*bi28)O|^7P;Gt@nv)*2jNEu76PK$F~z9t6N=P(K4#rL40GLbwsWK<7X*+jL%jL;9FO zdrLj7fZrid#qiEP{kQI09#|o8Pb}v88_)6xFEi_=*CeLsA-JOt^)4jTfDKI3jxR3ys?^jbM?*lwmlxSEffKLU0l7P;71CGsstmPpx1pm48eoyM&OR8baDAYmwg6@}yq z)&jn`3<|Bp+W}lR{(49lFhpnDxGY#$+Cg;fT>hnCK=seo8SMakR>1=(I$P?5~Q3r4ReR0XxEDLNG?|m+i}W zfodpWkcBdOWI$=8wz3~m}iSk;j)t3|QV z&L5}Yo-X)VDtNC0(x$Nig4{q#1o$X1XmeCg3j1iJqaQ$$31Fbq-I9w8HDjKZu;ULk zdP-uq5VjBm^N=(57@&fN2+F7sD~4D;vKR?5Ku_K(ooD9xz=tD7MisxE%!i<4ebnlu z4GbshG+S<)32;v>NKxfZ?z$@kL&>9qTB={)HD57DP}uP*_fmu?u5-DA+h z+}sb4&#zo<@BM%Kx2M%63>t74!-Gta?LqeNmz>Q!WJ-YLIOs9qDQJ23+F487+*zBt z9}%XeaxuKeD>|ECOi+~$rVn5Lk;^z3;sMaBRBf^2@*#SCd-1Aie)h2vP~m|%<<&d- z?)R=9_n#Ak6Ulk9j85}MO?x2`PnM;*RFpy=9jN6qhsS)YMd8eH&elw5CvyB^_l517 zJO>3xQ3*IvphNLOeAHy)d&$AnAnJ|5Vc23EhCI?C1A}@mXJoRLwh(YrK!@P*8=V{F z$>EP6FN9T+28x>}ZCW;C zhNBCHur9`L!fCzaH+okmn%14t{8ednI54Ys+N zyt~LmBC=v!$!fv{h?_=+C1>H#@<{oS_=)%_K8~*nd!x1<{36_4K~+TvRhQ56t@!15HTxsKi=o^Lvr7`NT z^6ihlviy+Kp3qnWP6ynIhGZH4m8G;K>~lH&vR+L4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/themes/black/client/src/img/stores/googleplay-badge.svg b/themes/black/client/src/img/stores/googleplay-badge.svg new file mode 100644 index 00000000..9e33e3aa --- /dev/null +++ b/themes/black/client/src/img/stores/googleplay-badge.svg @@ -0,0 +1,429 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/themes/black/client/src/img/success.png b/themes/black/client/src/img/success.png new file mode 100644 index 0000000000000000000000000000000000000000..ee9d6841bbb7208220a831c1c42917eb8facffa5 GIT binary patch literal 3147 zcmV-R47Br!P)@~8FWQhbW?9;ba!ELWdL_~cP?peYja~^ zaAhuUa%Y?FJQ@H13&=@CK~#9!?VWj0Ro5NIWpwOJQDRbwNDwr}T5D`fYExS^ZNS!! z({?)3WhP^$({{SdG*eL?n@DR!L!g~W+NQSuv}t7VDXU9R+=7CjxFCufZipa@@1Y=Q z0fk@B56^R+j|cDGckj99-22XPzT=Dw+=`0CLjfNNgnC0Sw=jZrT^)0zv?CqKirDB=}*$K3Qy8 z0m#XXB}uo)ojuDZ2JEA;mJ)zo>nkK+#i=mr-oOa>%SoIPs_I~$EjO(IrB0#^a(-M+lx<9lN`o{IqVIbS~NiSdXS z#6y4#+4;gG!%zX}@U9%;6Klk9xgi43i5)q%FXr4q1XBU%l&+lB6MGhMEK32>WhV=t zbS47Ol|1sgZ;tzc#kvYW*YL?@T;^I{Hcn3gY_d~L7n?2u(7S%QdRK>hyX-yq-N}z3 znAFu7~3~!grS-_H?$ML&Q-^1W1)o)xkf}aUEDS zXKy#WS(dKX@un&fK(6jp0srJ3cJN)TsPU9;tBFSyfR$Ka zd)I0$W;x!Q0MQcg=vhqQ*W9@QJld}-vWEn~JeRu~UMbZiL9{mkHcH5&!#{nO4}5H| z@5p!}0A^CoDwww?#ozPU=uLo133>$hpKXBJyN%r7clAAhXR=m5B>*NQI~L~d_Dh1w zrv#uS*ZU=G@!(^7r^-GIzAF>6VqwmwYy7El9_%i_G6~x|{4VmS0-$Z zh1t83)t=uncL6eabTBT7di&VFV8i+CU~jB~e_YuQf2hfVm_zS#hrdJs`1PG_Fk@S) zPXRE&n^(fD9Z71=G1FauGNU{At)rXZ(!Cb1Cyq;{{(89-7O-72H!A$IoZ(+0@TYHE zs|db(UMx;jdY)zO0<;*}!JEiWI!<)>YUjF|;d_VwY$$wOpI6t}#1;Obs;}|&gAf2c_BYu$vKA@y7)sad{=9{?}TNC zGnpRSw-7%0#NpX45cpABwZnHMY?}>U^|43<1y~_6d{?`1-vQrcBmu%d&l&#tyDhrH zcjfuf=MPq(0BNGbe#g z>Rjl$#TEX!JGYp^cjejK)$o>w3e-M>eC{6(T{j!bOv~xH8b;KQ(l`Gr0`~ zaOf?-%9?CLJN0sRtIw6Q`1keqTDihMBMAH_5|E$(eR>Pt7 zZATNlU{4kXzAH=wz#T1ICQ!)7J6_{G;p1gO!uLLn%;XaA!-^01&7!$UfH#P|;h%Ok zLHHK&;M2##0VX$rU)}ddXnt^q-pY4~L!-c(xknO=02F!J%GekAc5E`5&K^ zF<7?Vfqn_!M<(0jx|j_FVG5d#Lm;+O&PJHJB}r)bbfT!8=}_?353Gao+m`|g{z->9 z;A5ud*{(643_=Mi9DyOh3C9fxGYNdUXr#uF>0qpxbHq_E;R}|Cg7_VVEeKItHOoxx zxC!B7rWGXo#n2^T{#MTtxmDk8fGN3&rh`u}0Y5iMu|$jr`|&+n1%V%$6K@&#bO2~Oh*~5{gss_DhL8E;&g7qh z$YtVC0^VD+%iFG468@x|Rgb)ui37n3MZUptw{Cgs6_{*6_;d|G9hjw5ERT(whg&aO z7Ct6CFaGynree`X?%~6+eJjI%HfL2Yn6X?uO2B$`Z$r1!((vj0e*u{Fg%K#hEk-Mi zdmi>en29HLlnwq26_tDm%>2q2-1*b`zq`7^fAFXuUM@_tG<-Vue;%yx(x~Vm2|9Iw zfB#`$K)}b1ptE-+$ADE{8;3i7+B8al@V{KR-tzDt5B=Jjz-lj!T&h6=gg>X?-GG3P znO>CmHdyi1u}jM`pyffQgg<(Ls5@Zgmq%m4s2e>_Mev=yJ@BOff=_S$;=$^#kAHpt zI{4_~K^PeHz84YqDCS(PhDly(?pryb_PCp%<@NDYfC!Rq(IvoQh>Lb-K+^Fd*wuIz z>bu%ufAa-ccWM{RFMcnez|%#agENW~=V=yzN?f)qcl}Oe#7qFUrvj&xK)k#DsG@}VYI&*_fJ(63EcXL}B0+dw{Ca;_`XvCB zAj3?rfU~j$5e4zP{pF}dfFKgPlz{tCFIIUAGF$++rR4jh1l)=|-Ed#xZMXnbf(axi zqy&b~@jG5AT{}@LKg|MA2|`6%O>AWe$m3r3%ezyi>cLJI0jLC#QUdmQE_d~VIiIeX zr588d1aSK#ke35?+q&-0C{CQICo^3IaJwgvH-`Gx^=+S4kPxOfFWm)jdpeMhi26P1 zogvr!&#_>|L;$yOf($ugsBLEN#p1-RTAuT{3E(koEawi?*YbZjYiH6gSo2{nfZGI; zoJCY#!ljMe>CZHRjUfWKO(mzxMJ1!};33!hJ!+?PO(B5C$|AYEZ20qiPh?@jFOBA5 zxBzZz3~gpvZOl>v@@zjqz<;cC4WA_h@VGa~4@3#@4xc>NpPsiTWjap=Tm|shfm9v{ z65w?{JTJa!ZpoS-h|u>!2;i|HwbZH-gxcd;UfPlL6OsB{C;>d`6BLQ~#GqCJ{HUL@ z&327^+`lDaUkfRKN4c6*(PkNaNr10pCC~ErrWYkQ%Z2PeMs@COMQUL#i2&V5l z$fR#RVfp%-7WzFh>Gupad;ghUfDxn!p*NB%=z9@!=^Iij=)01y)3>Jg(KzUz)zW`c lLjO-1{TleSLaf+V{|9F)WMK{Ekgos$002ovPDHLkV1l3q_6h(1 literal 0 HcmV?d00001 diff --git a/themes/black/client/src/img/user.png b/themes/black/client/src/img/user.png new file mode 100644 index 0000000000000000000000000000000000000000..00941399d34f0f2d85d323f61daa67c2c00c494d GIT binary patch literal 2933 zcmV-*3ySoKP)>+Y_1buC zheF*1yf*GR)m>@otnGcw%sqGZe)vfU+q>t^|F^R<_ujcTYIJlo%4jV9WAaR39I1IcZ1yCpS zIY0`~LOAsCXaEv{=cNF_2S1lxgn0!F-rv{6d0@U2VDjK!2d)?(^zpb(O#~{z<1L%h zz`>Ihfp_q;fNbDV1ck2z&vq|HoB+`1Xdnp~jL_hfe8nynpNI$nprH$ZHW~ak_zd`^ zVFiH3O##fnhzxsNhFJ&KlZFxi8uS9|F_R$&2KI2yF@ylnkj21V8EVk<1HThm0M?5q z!wen#x-@Ay#-LU3Q-KpQK-IVm#0Vh(6gr)CbjaY;1?MwNCoKRJI2)*zVQI!~;5m{4 zKw;knT4hL@Q-{ZzlmKWYD~#$v`_Iw@bH~becUG-S%q-brIc!hQ%CTE>@`@1rH~t^~ z41WiI7ypKEd+>ucn}h)DJO4JN0|WtfTGFky((<~-_O71$J^cftp@4sjuYs?HuSraT zYBjD2!VN!$U7T<~AWPy*p3UYD9S&zzZK#1y2EI1F2fi1fQN^iT1YmRXE_461$D3Uj zt`?WMJ^jO41wR&iFMLmYZvqmW;3a^GcjNIpyUo>oPp{y|g71wr5Rf26R{^k#lJlU> z;E|J`7V-*yELaPHq3y-G2*4(4_H+EMA1JSBYNZms53C7m<0t@*x4fXK060v^Myhx< z^3f-GCAX-B?*nV&Ji7@;T&HL$KpM}}+_t|Sw^I$@2iC~>tH7*@061HPO<3U4u(#Ll zsCIk$ywnB28nIT+BZv!B6#(a}u_;U3%(j=$=Do z#TDQ{E@c6dl>%I)Smctph;09_P|%>BeJ>}(((xfktJ z7s0;&KLKzvB#H$t|I_wH(ZlzFZ6PYb%qX@a7P;cyPd?-@iU8Oaq5{l|V%wsTEAFGy z*NTiH0Jepw04t){-gxASJA0|%rcngIwottjh+=#5NC6^K0BaQ6B~uCznF16>v8__2 z0Ffy`B??e41&CMy8c~2ADL}*u(1`*(ASuA*0;d!}b9zyLhw=;%u>#yj0luVI+|iR) z3XLLwHTR!XJA#H#02jsLHvDC;VR{11e>-6#D!>C2powB}vGJxJqX>XY&=VEFjRI6q zowRWAT5*}^;a|S)yiN7^;*#@GRF-=mZ=;7B#TC_}hF@9V(it0Xb`w6nHnOvU6tjZv zQCu5%GVtsb8)v#heEXR*D8MP|`{X294%5Wjv`g+KzVLjis&La`#O6b=`CF3!uv2g`gE;3*M` z#;x4QFBQM@Sb;#{W9HL+)51GMDB4+3-$eJ~&*NPZXX+P7fL8JkhK%LUCb~0)Po2vz z6DWM-oDh$Ojp1QkbGw`1CJ`gcS6NyF6W{}-0KXTd@WgEg$(;rE9XVelSop{vlmbkn zIxoQA5N1tN8<~EArgql=;phJ*x&WLxc7cc+qBP*Wtyu+B1lX9gzgWOezti6eA)5ti zfVZT)u7UgBK&6$ng5CG~*y2xspNrIpH&+Myu$D9>mOOMdV9Tq;UhoxC%|Js zpGXZc?K;kFCGqsE9J`?5w@)Hw&vy)6A®tx`|Cc!K{R~B2jc1N|ng#YPY z01+^g*}>)+v;a(kg*ZQ7)0sYS5YJw62)S4!2N7^)@}f|(3`qbcL4r0*!k9bJT`jgp}PPI5fXrZ%isracGF5;IdBtzN$?_Y z46k-0FnAp9&IZ2EGXt&yC`6b8>;=AxfZ)FdvVb2@IQAq2P>3)S*uhSVMm*K;1fHjK z%t;BL5Mc(81b7S(JhovA@HE9^O;P}b2rkn{6#(u#g!>KLW&ICB?B8ANXZSm~9Q{dlI$z1o fINvtfu|oa}IMcn@FFyTa00000NkvXXu0mjfV5=n> literal 0 HcmV?d00001 diff --git a/themes/black/client/src/img/warning.png b/themes/black/client/src/img/warning.png new file mode 100644 index 0000000000000000000000000000000000000000..c6acd953b142a8e43e293c353a60e6313a8d39eb GIT binary patch literal 4038 zcmZ8kc{CK>7k@Km%nZhmB)g(al4J@g%*Ybi@{R0UV<~HPGGh#)vXiopt-hj2MImcp zY$;2&EGf;GWSK0JWq#AYzjw}k@4R>Jx$oW2UEdvZQv)=Q7!LpdZFo`Fg0oWo5|W!U zw`;lda28mgj^Py~XG9{237kF3|DsJG09+w|2|^mC_HqtIgY>L}{_%4U3Uvu^1EHaz zO4oh80%A^>WZHKCL(h;uQN z$(4fz2^gkydwnSstYvq?z;jH?4dHpjOIsi?F~5mv-=X~B1+IfT=c@TV0XW+s38jp3 zIm`8ol6U1Y3*FZ4G|uCdEr5XCymsumB+=8f9+kg$8eP;^u9f!rnogW5jie4Qx&UZq%;ioEF43zF#7yy~zM-eI zO?nd_=$d}*1Zdj89sj6I?h<22KC>b!%CwF;CBL2iff7f&(SHL%Y3gOm9%rm>z`BQ< zags$B!oM7@imA=`0F#zKMyK{>K{K;0WBK$OE(4l07$MAiJTzu}?T}$Eu6~)JEgc2l zf=SEgU@ShC_l!E7E{fG`pTiY)xdsO#!EcwQcM9VpAIi>eEYTaUlkwl)(ssK%=%={( zyLCh_G^+Jtx;(pcl>ozzx!j?_lkK#SDH7&caoLxQxBIRw{7*%VTL*&Jr`VX~UcGAs z20}q%Os|>1VApPeKz|^ki=-gYS z9Y@17L~Ki*qd&(F9GL7rg&nPk`xaYE{WG~yqtOG|AGd;=yeICQ3?P8Wl(?P zFZ7V8e_`3g%Z~$IWK8`8B+2Uaw1D@FC+7 z-$J8b@&2r&R`X87_vAo=1n`SqVsgn1)n)~(GkKZ^+y9#v%9lDe!PdCl<}d?4l&Z)= zLn&Pk*;lOwuF)@Xq&>Lrv$yIxxEBk9P*f6q;C&Gxctr z>UH@f5#<~SKVIH7{8TvYp}h5)?t8@rj)0$ z=(|UP)`a4gb}Nfh$}-Aot~qRv-2NG&Pw?!YJ_L{-cI0;gx6C17f#<(JU-shN4s}Xm zsr`bk<+y&nAzmdTfd%Fm{E6d?o6#b_8AlL0OZR6X0~$FN0^=Va&iV>~OZ62FfY!fr z+G@7W*EE5ULNUF6+k=dc=OuP(y~w^QFmG}IU~z1DYm-#g5&k$r%25&3Dt&RF(P6Qy z!hED53NQMhpoEN40!T@U!U(PZ!f|Zebkh%KFy7&Ljg0S%@80Y9{`L*_p0Gq(-Xa_n zWwVhZa3tlb{&BvDJc*;YmC03wmleLmlfhCIJloH!f053eR9|}uD3}(DuE;rORKDF~ z8OF`gF;dSjm&vc;j;H-8dLiCxQey{Hz=`iQ)BzERRTZji7F7reYFTV&VRD5#mM;P( zOnzY7S=<_1pZRu#%rT>*GCwrZtj>RI@Q?16O+_|h&X4@_qT z{7RG|7N$Y~RTfv^0XLX@uwvGl4+Up|W7er#Ssc0MwmZo`pUyz>8<(%}N)*8iLT<*$ zO7nn-T=u0({;o{JGM*TzB-T(t)5aqsCE#OXne$tyGP|&oKl$KN3NP@5DkD^g0|1p@ z4(AS`US%VnTz+8Z^%`KGKzv~a`sOjR&m!IZiOqmEvGch4*@fGRIo{5gpM0we7|#}f z^CTPH)gw)SBkBRwS>h6OBTukHNYUs@{TU=wUvp<|w38svkaGfXF17 zWRj^r4BA@-?+x!da`Cm2p9d}nF!q(p6NOeC5{5={Kr7n#qM8v0M{@o_vGlW5W}(rbH_w)a22X{DsMW>q`VKlT zXk-Y086nM7%Po?soy))lmj}z#H&QEp9}4F_KI_@4j-*^)ygEts*RkiKn62eo$G03- zOi`)M-~=((Ir@vJgpNwW2eeFB3y_uX|F=QcfJs>&llMuD- z;!BoDIu2Ioq}!HTPb2l-x0v3yVVB%8h?y?A85*|%wy3iFW1CtV+P>P+YD!=Qp&6nh zpIe}j|SXo%pJ_yAdzPv0Hd-05QBz!d!eVX!e_ z-js2+CCJYh%8^@Wg9~fkF6Qu>bT&Ro^dIN&*5>tgan0OP%w$V%fA-%-^*%>}*H>=U z!jTaxwGN=$~V1I2jb9T`^h|OQK?o+N?D|4 z%z=m!488|@gJ9jym%RV<;yy5CvE=1Zp4VzOXMit87iM+GU@MG@F&tH|m(TSZJC+>> zVV7QsOjvSjJ`@G(vuKq+ovB&m7i#;On+EK^sDZq)O`%>Aot~S$T6tk$6miZ z37nON_ttN@aBChO!q9G=2`91_S^1OMbP-+Qzb~7M7k%ZmY*tzbI>2DcboU%unRGe_ z^g6>eZQIZhx<$yz=BE-TqN+ya*KDr>e5)V=r(T0o09y}kAUswax2W8N6l5)zy{FXT zBCBMbJKg8c8{RJV4?lJfI=NJj3;36BD84{BOqIIaih|ifAHPMW@E@HB`s-w(SAV(o z8BYmb{tLZdgx&WRQ_Flp++DL9J%{!bU5L_O@OV^m-WN_@GfqN+t#6a>ZJ)fIEc54H z_Ahf?jW>B!W)Hfw>LIikDG8h3(g;8IH78vtMT}9pZk9gqaafF_b|zx9h71z{H7U<{ z+UCstV>w|G^N5qtm{_m!4V!Cc_#$GTw0CkB!PSFBD(8Zm(v9+Jmfk6V{CR2+G|9x>_Jus|!Ap5u#`Lp(Fwz*3I*OOGUT(q-M!Afy{tE@U zJ2F=Cu$IgfDXw+ZF2+tPekHA`bg@1(%I2vkB|8wwf zRU;Ls;@*5M8xL*Febm=t`gFhF?0_;=b~>tWNj@wC1yr}RMl6=> zX6-I}2_osksR_lEmhYexY@erD5yZS_@mPt{mq!Rag+PIruP1DNV3J?1b5O|b#ua+1 zKK*Qlxf{Zjnf!_bXYF=)Kg>LK0X;cl;x&6Us;m43gbnAydy`^s!rJ|y34o}Mlo51w#*DkxKu*ZSO^rjzY4`S%iTMPk?%#D9Hq0u zXO7WS9c7da`CWXmSEK4H`Zo6u$TGFji_X0wF zYHis}o|pf`w?RCV<8AhzFDtAF=BVX(@&7)kpvZHP-ER?mQZc1eo%81ghI*#D&vcxr F{{xrIJYfI; literal 0 HcmV?d00001 diff --git a/themes/black/client/src/index.ts b/themes/black/client/src/index.ts new file mode 100644 index 00000000..802004a8 --- /dev/null +++ b/themes/black/client/src/index.ts @@ -0,0 +1,34 @@ + +import FirstFactorValidator = require("./lib/firstfactor/FirstFactorValidator"); + +import FirstFactor from "./lib/firstfactor/index"; +import SecondFactor from "./lib/secondfactor/index"; +import TOTPRegister from "./lib/totp-register/totp-register"; +import U2fRegister from "./lib/u2f-register/u2f-register"; +import ResetPasswordRequest from "./lib/reset-password/reset-password-request"; +import ResetPasswordForm from "./lib/reset-password/reset-password-form"; +import jslogger = require("js-logger"); +import jQuery = require("jquery"); +import Endpoints = require("../../shared/api"); + +jslogger.useDefaults(); +jslogger.setLevel(jslogger.INFO); + +(function () { + (window).jQuery = jQuery; + require("bootstrap"); + + jQuery('[data-toggle="tooltip"]').tooltip(); + if (window.location.pathname == Endpoints.FIRST_FACTOR_GET) + FirstFactor(window, jQuery, FirstFactorValidator, jslogger); + else if (window.location.pathname == Endpoints.SECOND_FACTOR_GET) + SecondFactor(window, jQuery, (global as any).u2f); + else if (window.location.pathname == Endpoints.SECOND_FACTOR_TOTP_IDENTITY_FINISH_GET) + TOTPRegister(window, jQuery); + else if (window.location.pathname == Endpoints.SECOND_FACTOR_U2F_IDENTITY_FINISH_GET) + U2fRegister(window, jQuery, (global as any).u2f); + else if (window.location.pathname == Endpoints.RESET_PASSWORD_IDENTITY_FINISH_GET) + ResetPasswordForm(window, jQuery); + else if (window.location.pathname == Endpoints.RESET_PASSWORD_REQUEST_GET) + ResetPasswordRequest(window, jQuery); +})(); diff --git a/themes/black/client/src/lib/GetPromised.ts b/themes/black/client/src/lib/GetPromised.ts new file mode 100644 index 00000000..77913965 --- /dev/null +++ b/themes/black/client/src/lib/GetPromised.ts @@ -0,0 +1,14 @@ +import BluebirdPromise = require("bluebird"); + +export default function ($: JQueryStatic, url: string, data: Object, fn: any, + dataType: string): BluebirdPromise { + return new BluebirdPromise((resolve, reject) => { + $.get(url, {}, undefined, dataType) + .done((data: any) => { + resolve(data); + }) + .fail((xhr: JQueryXHR, textStatus: string) => { + reject(textStatus); + }); + }); +} \ No newline at end of file diff --git a/themes/black/client/src/lib/INotifier.ts b/themes/black/client/src/lib/INotifier.ts new file mode 100644 index 00000000..df947538 --- /dev/null +++ b/themes/black/client/src/lib/INotifier.ts @@ -0,0 +1,14 @@ + +declare type Handler = () => void; + +export interface Handlers { + onFadedIn: Handler; + onFadedOut: Handler; +} + +export interface INotifier { + success(msg: string, handlers?: Handlers): void; + error(msg: string, handlers?: Handlers): void; + warning(msg: string, handlers?: Handlers): void; + info(msg: string, handlers?: Handlers): void; +} \ No newline at end of file diff --git a/themes/black/client/src/lib/Notifier.ts b/themes/black/client/src/lib/Notifier.ts new file mode 100644 index 00000000..c0252b9b --- /dev/null +++ b/themes/black/client/src/lib/Notifier.ts @@ -0,0 +1,83 @@ + + +import util = require("util"); +import { INotifier, Handlers } from "./INotifier"; + +class NotificationEvent { + private element: JQuery; + private message: string; + private statusType: string; + private timeoutId: any; + + constructor(element: JQuery, msg: string, statusType: string) { + this.message = msg; + this.statusType = statusType; + this.element = element; + } + + private clearNotification() { + this.element.removeClass(this.statusType); + this.element.html(""); + } + + start(handlers?: Handlers) { + const that = this; + const FADE_TIME = 500; + const html = util.format('status %s\ + %s', this.statusType, this.statusType, this.message); + this.element.html(html); + this.element.addClass(this.statusType); + this.element.fadeIn(FADE_TIME, function () { + if (handlers) + handlers.onFadedIn(); + }); + + this.timeoutId = setTimeout(function () { + that.element.fadeOut(FADE_TIME, function () { + that.clearNotification(); + if (handlers) + handlers.onFadedOut(); + }); + }, 4000); + } + + interrupt() { + this.clearNotification(); + this.element.hide(); + clearTimeout(this.timeoutId); + } +} + +export class Notifier implements INotifier { + private element: JQuery; + private onGoingEvent: NotificationEvent; + + constructor(selector: string, $: JQueryStatic) { + this.element = $(selector); + this.onGoingEvent = undefined; + } + + private displayAndFadeout(msg: string, statusType: string, handlers?: Handlers): void { + if (this.onGoingEvent) + this.onGoingEvent.interrupt(); + + this.onGoingEvent = new NotificationEvent(this.element, msg, statusType); + this.onGoingEvent.start(handlers); + } + + success(msg: string, handlers?: Handlers) { + this.displayAndFadeout(msg, "success", handlers); + } + + error(msg: string, handlers?: Handlers) { + this.displayAndFadeout(msg, "error", handlers); + } + + warning(msg: string, handlers?: Handlers) { + this.displayAndFadeout(msg, "warning", handlers); + } + + info(msg: string, handlers?: Handlers) { + this.displayAndFadeout(msg, "info", handlers); + } +} \ No newline at end of file diff --git a/themes/black/client/src/lib/QueryParametersRetriever.ts b/themes/black/client/src/lib/QueryParametersRetriever.ts new file mode 100644 index 00000000..a529adb6 --- /dev/null +++ b/themes/black/client/src/lib/QueryParametersRetriever.ts @@ -0,0 +1,12 @@ + +export class QueryParametersRetriever { + static get(name: string, url?: string): string { + if (!url) url = window.location.href; + name = name.replace(/[\[\]]/g, "\\$&"); + const regex = new RegExp("[?&]" + name + "(=([^&#]*)|&|#|$)"), + results = regex.exec(url); + if (!results) return undefined; + if (!results[2]) return ""; + return decodeURIComponent(results[2].replace(/\+/g, " ")); + } +} \ No newline at end of file diff --git a/themes/black/client/src/lib/SafeRedirect.ts b/themes/black/client/src/lib/SafeRedirect.ts new file mode 100644 index 00000000..7e7684b8 --- /dev/null +++ b/themes/black/client/src/lib/SafeRedirect.ts @@ -0,0 +1,10 @@ +import { BelongToDomain } from "../../../shared/BelongToDomain"; + +export function SafeRedirect(url: string, cb: () => void): void { + const domain = window.location.hostname.split(".").slice(-2).join("."); + if (url.startsWith("/") || BelongToDomain(url, domain)) { + window.location.href = url; + return; + } + cb(); +} \ No newline at end of file diff --git a/themes/black/client/src/lib/firstfactor/FirstFactorValidator.ts b/themes/black/client/src/lib/firstfactor/FirstFactorValidator.ts new file mode 100644 index 00000000..eaa496fd --- /dev/null +++ b/themes/black/client/src/lib/firstfactor/FirstFactorValidator.ts @@ -0,0 +1,46 @@ + +import BluebirdPromise = require("bluebird"); +import Endpoints = require("../../../../shared/api"); +import Constants = require("../../../../shared/constants"); +import Util = require("util"); +import UserMessages = require("../../../../shared/UserMessages"); + +export function validate(username: string, password: string, + keepMeLoggedIn: boolean, redirectUrl: string, $: JQueryStatic) + : BluebirdPromise { + return new BluebirdPromise(function (resolve, reject) { + let url: string; + if (redirectUrl != undefined) { + const redirectParam = Util.format("%s=%s", Constants.REDIRECT_QUERY_PARAM, redirectUrl); + url = Util.format("%s?%s", Endpoints.FIRST_FACTOR_POST, redirectParam); + } + else { + url = Util.format("%s", Endpoints.FIRST_FACTOR_POST); + } + + const data: any = { + username: username, + password: password, + }; + + if (keepMeLoggedIn) { + data.keepMeLoggedIn = "true"; + } + + $.ajax({ + method: "POST", + url: url, + data: data + }) + .done(function (body: any) { + if (body && body.error) { + reject(new Error(body.error)); + return; + } + resolve(body.redirect); + }) + .fail(function (xhr: JQueryXHR, textStatus: string) { + reject(new Error(UserMessages.AUTHENTICATION_FAILED)); + }); + }); +} diff --git a/themes/black/client/src/lib/firstfactor/UISelectors.ts b/themes/black/client/src/lib/firstfactor/UISelectors.ts new file mode 100644 index 00000000..0e971b3c --- /dev/null +++ b/themes/black/client/src/lib/firstfactor/UISelectors.ts @@ -0,0 +1,5 @@ + +export const USERNAME_FIELD_ID = "#username"; +export const PASSWORD_FIELD_ID = "#password"; +export const SIGN_IN_BUTTON_ID = "#signin"; +export const KEEP_ME_LOGGED_IN_ID = "#keep_me_logged_in"; diff --git a/themes/black/client/src/lib/firstfactor/index.ts b/themes/black/client/src/lib/firstfactor/index.ts new file mode 100644 index 00000000..24affee2 --- /dev/null +++ b/themes/black/client/src/lib/firstfactor/index.ts @@ -0,0 +1,49 @@ +import FirstFactorValidator = require("./FirstFactorValidator"); +import JSLogger = require("js-logger"); +import UISelectors = require("./UISelectors"); +import { Notifier } from "../Notifier"; +import { QueryParametersRetriever } from "../QueryParametersRetriever"; +import Constants = require("../../../../shared/constants"); +import Endpoints = require("../../../../shared/api"); +import UserMessages = require("../../../../shared/UserMessages"); +import { SafeRedirect } from "../SafeRedirect"; + +export default function (window: Window, $: JQueryStatic, + firstFactorValidator: typeof FirstFactorValidator, jslogger: typeof JSLogger) { + + const notifier = new Notifier(".notification", $); + + function onFormSubmitted() { + const username: string = $(UISelectors.USERNAME_FIELD_ID).val() as string; + const password: string = $(UISelectors.PASSWORD_FIELD_ID).val() as string; + const keepMeLoggedIn: boolean = $(UISelectors.KEEP_ME_LOGGED_IN_ID).is(":checked"); + + $("form").css("opacity", 0.5); + $("input,button").attr("disabled", "true"); + $(UISelectors.SIGN_IN_BUTTON_ID).text("Please wait..."); + + const redirectUrl = QueryParametersRetriever.get(Constants.REDIRECT_QUERY_PARAM); + firstFactorValidator.validate(username, password, keepMeLoggedIn, redirectUrl, $) + .then(onFirstFactorSuccess, onFirstFactorFailure); + return false; + } + + function onFirstFactorSuccess(redirectUrl: string) { + SafeRedirect(redirectUrl, () => { + notifier.error("Cannot redirect to an external domain."); + }); + } + + function onFirstFactorFailure(err: Error) { + $("input,button").removeAttr("disabled"); + $("form").css("opacity", 1); + notifier.error(UserMessages.AUTHENTICATION_FAILED); + $(UISelectors.PASSWORD_FIELD_ID).select(); + $(UISelectors.SIGN_IN_BUTTON_ID).text("Sign in"); + } + + $(window.document).ready(function () { + $("form").on("submit", onFormSubmitted); + }); +} + diff --git a/themes/black/client/src/lib/reset-password/constants.ts b/themes/black/client/src/lib/reset-password/constants.ts new file mode 100644 index 00000000..d48d4e67 --- /dev/null +++ b/themes/black/client/src/lib/reset-password/constants.ts @@ -0,0 +1,2 @@ + +export const FORM_SELECTOR = ".form-signin"; \ No newline at end of file diff --git a/themes/black/client/src/lib/reset-password/reset-password-form.ts b/themes/black/client/src/lib/reset-password/reset-password-form.ts new file mode 100644 index 00000000..b94279cd --- /dev/null +++ b/themes/black/client/src/lib/reset-password/reset-password-form.ts @@ -0,0 +1,57 @@ +import BluebirdPromise = require("bluebird"); + +import Endpoints = require("../../../../shared/api"); +import UserMessages = require("../../../../shared/UserMessages"); + +import Constants = require("./constants"); +import { Notifier } from "../Notifier"; + +export default function (window: Window, $: JQueryStatic) { + const notifier = new Notifier(".notification", $); + + function modifyPassword(newPassword: string) { + return new BluebirdPromise(function (resolve, reject) { + $.post(Endpoints.RESET_PASSWORD_FORM_POST, { + password: newPassword, + }) + .done(function (body: any) { + if (body && body.error) { + reject(new Error(body.error)); + return; + } + resolve(body); + }) + .fail(function (xhr, status) { + reject(status); + }); + }); + } + + function onFormSubmitted() { + const password1 = $("#password1").val() as string; + const password2 = $("#password2").val() as string; + + if (!password1 || !password2) { + notifier.warning(UserMessages.MISSING_PASSWORD); + return false; + } + + if (password1 != password2) { + notifier.warning(UserMessages.DIFFERENT_PASSWORDS); + return false; + } + + modifyPassword(password1) + .then(function () { + window.location.href = Endpoints.FIRST_FACTOR_GET; + }) + .error(function () { + notifier.error(UserMessages.RESET_PASSWORD_FAILED); + }); + return false; + } + + $(document).ready(function () { + $(Constants.FORM_SELECTOR).on("submit", onFormSubmitted); + }); +} diff --git a/themes/black/client/src/lib/reset-password/reset-password-request.ts b/themes/black/client/src/lib/reset-password/reset-password-request.ts new file mode 100644 index 00000000..846226d7 --- /dev/null +++ b/themes/black/client/src/lib/reset-password/reset-password-request.ts @@ -0,0 +1,56 @@ + +import BluebirdPromise = require("bluebird"); + +import Endpoints = require("../../../../shared/api"); +import UserMessages = require("../../../../shared/UserMessages"); +import Constants = require("./constants"); +import jslogger = require("js-logger"); +import { Notifier } from "../Notifier"; + +export default function (window: Window, $: JQueryStatic) { + const notifier = new Notifier(".notification", $); + + function requestPasswordReset(username: string) { + return new BluebirdPromise(function (resolve, reject) { + $.get(Endpoints.RESET_PASSWORD_IDENTITY_START_GET, { + userid: username, + }) + .done(function (body: any) { + if (body && body.error) { + reject(new Error(body.error)); + return; + } + resolve(); + }) + .fail(function (xhr: JQueryXHR, textStatus: string) { + reject(new Error(textStatus)); + }); + }); + } + + function onFormSubmitted() { + const username = $("#username").val() as string; + + if (!username) { + notifier.warning(UserMessages.MISSING_USERNAME); + return; + } + + requestPasswordReset(username) + .then(function () { + notifier.success(UserMessages.MAIL_SENT); + setTimeout(function () { + window.location.replace(Endpoints.FIRST_FACTOR_GET); + }, 1000); + }) + .error(function () { + notifier.error(UserMessages.MAIL_NOT_SENT); + }); + return false; + } + + $(document).ready(function () { + $(Constants.FORM_SELECTOR).on("submit", onFormSubmitted); + }); +} + diff --git a/themes/black/client/src/lib/secondfactor/TOTPValidator.ts b/themes/black/client/src/lib/secondfactor/TOTPValidator.ts new file mode 100644 index 00000000..5394139a --- /dev/null +++ b/themes/black/client/src/lib/secondfactor/TOTPValidator.ts @@ -0,0 +1,28 @@ + +import BluebirdPromise = require("bluebird"); +import Endpoints = require("../../../../shared/api"); +import { RedirectionMessage } from "../../../../shared/RedirectionMessage"; +import { ErrorMessage } from "../../../../shared/ErrorMessage"; + +export function validate(token: string, $: JQueryStatic): BluebirdPromise { + return new BluebirdPromise(function (resolve, reject) { + $.ajax({ + url: Endpoints.SECOND_FACTOR_TOTP_POST, + data: { + token: token, + }, + method: "POST", + dataType: "json" + } as JQueryAjaxSettings) + .done(function (body: RedirectionMessage | ErrorMessage) { + if (body && "error" in body) { + reject(new Error((body as ErrorMessage).error)); + return; + } + resolve((body as RedirectionMessage).redirect); + }) + .fail(function (xhr: JQueryXHR, textStatus: string) { + reject(new Error(textStatus)); + }); + }); +} \ No newline at end of file diff --git a/themes/black/client/src/lib/secondfactor/U2FValidator.ts b/themes/black/client/src/lib/secondfactor/U2FValidator.ts new file mode 100644 index 00000000..5812922f --- /dev/null +++ b/themes/black/client/src/lib/secondfactor/U2FValidator.ts @@ -0,0 +1,42 @@ +import U2f = require("u2f"); +import U2fApi from "u2f-api"; +import BluebirdPromise = require("bluebird"); +import Endpoints = require("../../../../shared/api"); +import UserMessages = require("../../../../shared/UserMessages"); +import { INotifier } from "../INotifier"; +import { RedirectionMessage } from "../../../../shared/RedirectionMessage"; +import { ErrorMessage } from "../../../../shared/ErrorMessage"; +import GetPromised from "../GetPromised"; + +function finishU2fAuthentication(responseData: U2fApi.SignResponse, + $: JQueryStatic): BluebirdPromise { + return new BluebirdPromise(function (resolve, reject) { + $.ajax({ + url: Endpoints.SECOND_FACTOR_U2F_SIGN_POST, + data: responseData, + method: "POST", + dataType: "json" + } as JQueryAjaxSettings) + .done(function (body: RedirectionMessage | ErrorMessage) { + if (body && "error" in body) { + reject(new Error((body as ErrorMessage).error)); + return; + } + resolve((body as RedirectionMessage).redirect); + }) + .fail(function (xhr: JQueryXHR, textStatus: string) { + reject(new Error(textStatus)); + }); + }); +} + +export function validate($: JQueryStatic): BluebirdPromise { + return GetPromised($, Endpoints.SECOND_FACTOR_U2F_SIGN_REQUEST_GET, {}, + undefined, "json") + .then(function (signRequest: U2f.Request) { + return U2fApi.sign(signRequest, 60); + }) + .then(function (signResponse: U2fApi.SignResponse) { + return finishU2fAuthentication(signResponse, $); + }); +} diff --git a/themes/black/client/src/lib/secondfactor/constants.ts b/themes/black/client/src/lib/secondfactor/constants.ts new file mode 100644 index 00000000..50bba757 --- /dev/null +++ b/themes/black/client/src/lib/secondfactor/constants.ts @@ -0,0 +1,3 @@ + +export const TOTP_FORM_SELECTOR = ".form-signin.totp"; +export const TOTP_TOKEN_SELECTOR = ".form-signin #token"; diff --git a/themes/black/client/src/lib/secondfactor/index.ts b/themes/black/client/src/lib/secondfactor/index.ts new file mode 100644 index 00000000..279723dc --- /dev/null +++ b/themes/black/client/src/lib/secondfactor/index.ts @@ -0,0 +1,59 @@ +import TOTPValidator = require("./TOTPValidator"); +import U2FValidator = require("./U2FValidator"); +import ClientConstants = require("./constants"); +import { Notifier } from "../Notifier"; +import { QueryParametersRetriever } from "../QueryParametersRetriever"; +import UserMessages = require("../../../../shared/UserMessages"); +import SharedConstants = require("../../../../shared/constants"); +import { SafeRedirect } from "../SafeRedirect"; + +export default function (window: Window, $: JQueryStatic) { + const notifier = new Notifier(".notification", $); + + function onAuthenticationSuccess(serverRedirectUrl: string) { + const queryRedirectUrl = QueryParametersRetriever.get(SharedConstants.REDIRECT_QUERY_PARAM); + if (queryRedirectUrl) { + SafeRedirect(queryRedirectUrl, () => { + notifier.error(UserMessages.CANNOT_REDIRECT_TO_EXTERNAL_DOMAIN); + }); + } else if (serverRedirectUrl) { + SafeRedirect(serverRedirectUrl, () => { + notifier.error(UserMessages.CANNOT_REDIRECT_TO_EXTERNAL_DOMAIN); + }); + } else { + notifier.success(UserMessages.AUTHENTICATION_SUCCEEDED); + } + } + + function onSecondFactorTotpSuccess(redirectUrl: string) { + onAuthenticationSuccess(redirectUrl); + } + + function onSecondFactorTotpFailure(err: Error) { + notifier.error(UserMessages.AUTHENTICATION_TOTP_FAILED); + } + + function onU2fAuthenticationSuccess(redirectUrl: string) { + onAuthenticationSuccess(redirectUrl); + } + + function onU2fAuthenticationFailure() { + // TODO(clems4ever): we should not display this error message until a device + // is registered. + // notifier.error(UserMessages.AUTHENTICATION_U2F_FAILED); + } + + function onTOTPFormSubmitted(): boolean { + const token = $(ClientConstants.TOTP_TOKEN_SELECTOR).val() as string; + TOTPValidator.validate(token, $) + .then(onSecondFactorTotpSuccess) + .catch(onSecondFactorTotpFailure); + return false; + } + + $(window.document).ready(function () { + $(ClientConstants.TOTP_FORM_SELECTOR).on("submit", onTOTPFormSubmitted); + U2FValidator.validate($) + .then(onU2fAuthenticationSuccess, onU2fAuthenticationFailure); + }); +} \ No newline at end of file diff --git a/themes/black/client/src/lib/totp-register/totp-register.ts b/themes/black/client/src/lib/totp-register/totp-register.ts new file mode 100644 index 00000000..6a9aa7ee --- /dev/null +++ b/themes/black/client/src/lib/totp-register/totp-register.ts @@ -0,0 +1,11 @@ + +import jslogger = require("js-logger"); +import UISelector = require("./ui-selector"); + +export default function(window: Window, $: JQueryStatic) { + jslogger.debug("Creating QRCode from OTPAuth url"); + const qrcode = $(UISelector.QRCODE_ID_SELECTOR); + const val = qrcode.text(); + qrcode.empty(); + new (window as any).QRCode(qrcode.get(0), val); +} diff --git a/themes/black/client/src/lib/totp-register/ui-selector.ts b/themes/black/client/src/lib/totp-register/ui-selector.ts new file mode 100644 index 00000000..9d43fabe --- /dev/null +++ b/themes/black/client/src/lib/totp-register/ui-selector.ts @@ -0,0 +1,2 @@ + +export const QRCODE_ID_SELECTOR = "#qrcode"; \ No newline at end of file diff --git a/themes/black/client/src/lib/u2f-register/u2f-register.ts b/themes/black/client/src/lib/u2f-register/u2f-register.ts new file mode 100644 index 00000000..abf40ee0 --- /dev/null +++ b/themes/black/client/src/lib/u2f-register/u2f-register.ts @@ -0,0 +1,56 @@ + +import BluebirdPromise = require("bluebird"); +import U2f = require("u2f"); +import * as U2fApi from "u2f-api"; +import { Notifier } from "../Notifier"; +import GetPromised from "../GetPromised"; +import Endpoints = require("../../../../shared/api"); +import UserMessages = require("../../../../shared/UserMessages"); +import { RedirectionMessage } from "../../../../shared/RedirectionMessage"; +import { ErrorMessage } from "../../../../shared/ErrorMessage"; +import { SafeRedirect } from "../SafeRedirect"; + +export default function (window: Window, $: JQueryStatic) { + const notifier = new Notifier(".notification", $); + + function checkRegistration(regResponse: U2fApi.RegisterResponse): BluebirdPromise { + return new BluebirdPromise(function (resolve, reject) { + $.post(Endpoints.SECOND_FACTOR_U2F_REGISTER_POST, regResponse, undefined, "json") + .done((body: RedirectionMessage | ErrorMessage) => { + if (body && "error" in body) { + reject(new Error((body as ErrorMessage).error)); + return; + } + resolve((body as RedirectionMessage).redirect); + }) + .fail((xhr, status) => { + reject(new Error("Failed to register device.")); + }); + }); + } + + function requestRegistration(): BluebirdPromise { + return GetPromised($, Endpoints.SECOND_FACTOR_U2F_REGISTER_REQUEST_GET, {}, + undefined, "json") + .then((registrationRequest: U2f.Request) => { + return U2fApi.register(registrationRequest, [], 60); + }) + .then((res) => checkRegistration(res)); + } + + function onRegisterFailure(err: Error) { + notifier.error(UserMessages.REGISTRATION_U2F_FAILED); + } + + $(document).ready(function () { + requestRegistration() + .then((redirectionUrl: string) => { + SafeRedirect(redirectionUrl, () => { + notifier.error(UserMessages.CANNOT_REDIRECT_TO_EXTERNAL_DOMAIN); + }); + }) + .catch((err) => { + onRegisterFailure(err); + }); + }); +} diff --git a/themes/black/client/src/thirdparties/qrcode.min.js b/themes/black/client/src/thirdparties/qrcode.min.js new file mode 100644 index 00000000..993e88f3 --- /dev/null +++ b/themes/black/client/src/thirdparties/qrcode.min.js @@ -0,0 +1 @@ +var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push('');g.push("")}g.push("
"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); \ No newline at end of file diff --git a/themes/black/client/src/thirdparties/u2f-api.js b/themes/black/client/src/thirdparties/u2f-api.js new file mode 100644 index 00000000..8c7801e3 --- /dev/null +++ b/themes/black/client/src/thirdparties/u2f-api.js @@ -0,0 +1,749 @@ +//Copyright 2014-2015 Google Inc. All rights reserved. + +//Use of this source code is governed by a BSD-style +//license that can be found in the LICENSE file or at +//https://developers.google.com/open-source/licenses/bsd + +/** + * @fileoverview The U2F api. + */ +'use strict'; + + +/** + * Namespace for the U2F api. + * @type {Object} + */ +var u2f = u2f || {}; + +/** + * FIDO U2F Javascript API Version + * @number + */ +var js_api_version; + +/** + * The U2F extension id + * @const {string} + */ +// The Chrome packaged app extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the package Chrome app and does not require installing the U2F Chrome extension. + u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd'; +// The U2F Chrome extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the U2F Chrome extension to authenticate. +// u2f.EXTENSION_ID = 'pfboblefjcgdjicmnffhdgionmgcdmne'; + + +/** + * Message types for messsages to/from the extension + * @const + * @enum {string} + */ +u2f.MessageTypes = { + 'U2F_REGISTER_REQUEST': 'u2f_register_request', + 'U2F_REGISTER_RESPONSE': 'u2f_register_response', + 'U2F_SIGN_REQUEST': 'u2f_sign_request', + 'U2F_SIGN_RESPONSE': 'u2f_sign_response', + 'U2F_GET_API_VERSION_REQUEST': 'u2f_get_api_version_request', + 'U2F_GET_API_VERSION_RESPONSE': 'u2f_get_api_version_response' +}; + + +/** + * Response status codes + * @const + * @enum {number} + */ +u2f.ErrorCodes = { + 'OK': 0, + 'OTHER_ERROR': 1, + 'BAD_REQUEST': 2, + 'CONFIGURATION_UNSUPPORTED': 3, + 'DEVICE_INELIGIBLE': 4, + 'TIMEOUT': 5 +}; + + +/** + * A message for registration requests + * @typedef {{ + * type: u2f.MessageTypes, + * appId: ?string, + * timeoutSeconds: ?number, + * requestId: ?number + * }} + */ +u2f.U2fRequest; + + +/** + * A message for registration responses + * @typedef {{ + * type: u2f.MessageTypes, + * responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse), + * requestId: ?number + * }} + */ +u2f.U2fResponse; + + +/** + * An error object for responses + * @typedef {{ + * errorCode: u2f.ErrorCodes, + * errorMessage: ?string + * }} + */ +u2f.Error; + +/** + * Data object for a single sign request. + * @typedef {enum {BLUETOOTH_RADIO, BLUETOOTH_LOW_ENERGY, USB, NFC}} + */ +u2f.Transport; + + +/** + * Data object for a single sign request. + * @typedef {Array} + */ +u2f.Transports; + +/** + * Data object for a single sign request. + * @typedef {{ + * version: string, + * challenge: string, + * keyHandle: string, + * appId: string + * }} + */ +u2f.SignRequest; + + +/** + * Data object for a sign response. + * @typedef {{ + * keyHandle: string, + * signatureData: string, + * clientData: string + * }} + */ +u2f.SignResponse; + + +/** + * Data object for a registration request. + * @typedef {{ + * version: string, + * challenge: string + * }} + */ +u2f.RegisterRequest; + + +/** + * Data object for a registration response. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: Transports, + * appId: string + * }} + */ +u2f.RegisterResponse; + + +/** + * Data object for a registered key. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: ?Transports, + * appId: ?string + * }} + */ +u2f.RegisteredKey; + + +/** + * Data object for a get API register response. + * @typedef {{ + * js_api_version: number + * }} + */ +u2f.GetJsApiVersionResponse; + + +//Low level MessagePort API support + +/** + * Sets up a MessagePort to the U2F extension using the + * available mechanisms. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + */ +u2f.getMessagePort = function(callback) { + if (typeof chrome != 'undefined' && chrome.runtime) { + // The actual message here does not matter, but we need to get a reply + // for the callback to run. Thus, send an empty signature request + // in order to get a failure response. + var msg = { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: [] + }; + chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() { + if (!chrome.runtime.lastError) { + // We are on a whitelisted origin and can talk directly + // with the extension. + u2f.getChromeRuntimePort_(callback); + } else { + // chrome.runtime was available, but we couldn't message + // the extension directly, use iframe + u2f.getIframePort_(callback); + } + }); + } else if (u2f.isAndroidChrome_()) { + u2f.getAuthenticatorPort_(callback); + } else if (u2f.isIosChrome_()) { + u2f.getIosPort_(callback); + } else { + // chrome.runtime was not available at all, which is normal + // when this origin doesn't have access to any extensions. + u2f.getIframePort_(callback); + } +}; + +/** + * Detect chrome running on android based on the browser's useragent. + * @private + */ +u2f.isAndroidChrome_ = function() { + var userAgent = navigator.userAgent; + return userAgent.indexOf('Chrome') != -1 && + userAgent.indexOf('Android') != -1; +}; + +/** + * Detect chrome running on iOS based on the browser's platform. + * @private + */ +u2f.isIosChrome_ = function() { + return ["iPhone", "iPad", "iPod"].indexOf(navigator.platform) > -1; +}; + +/** + * Connects directly to the extension via chrome.runtime.connect. + * @param {function(u2f.WrappedChromeRuntimePort_)} callback + * @private + */ +u2f.getChromeRuntimePort_ = function(callback) { + var port = chrome.runtime.connect(u2f.EXTENSION_ID, + {'includeTlsChannelId': true}); + setTimeout(function() { + callback(new u2f.WrappedChromeRuntimePort_(port)); + }, 0); +}; + +/** + * Return a 'port' abstraction to the Authenticator app. + * @param {function(u2f.WrappedAuthenticatorPort_)} callback + * @private + */ +u2f.getAuthenticatorPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedAuthenticatorPort_()); + }, 0); +}; + +/** + * Return a 'port' abstraction to the iOS client app. + * @param {function(u2f.WrappedIosPort_)} callback + * @private + */ +u2f.getIosPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedIosPort_()); + }, 0); +}; + +/** + * A wrapper for chrome.runtime.Port that is compatible with MessagePort. + * @param {Port} port + * @constructor + * @private + */ +u2f.WrappedChromeRuntimePort_ = function(port) { + this.port_ = port; +}; + +/** + * Format and return a sign request compliant with the JS API version supported by the extension. + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatSignRequest_ = + function(appId, challenge, registeredKeys, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: challenge, + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: signRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + appId: appId, + challenge: challenge, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + +/** + * Format and return a register request compliant with the JS API version supported by the extension.. + * @param {Array} signRequests + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatRegisterRequest_ = + function(appId, registeredKeys, registerRequests, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + for (var i = 0; i < registerRequests.length; i++) { + registerRequests[i].appId = appId; + } + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: registerRequests[0], + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + signRequests: signRequests, + registerRequests: registerRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + appId: appId, + registerRequests: registerRequests, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + + +/** + * Posts a message on the underlying channel. + * @param {Object} message + */ +u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) { + this.port_.postMessage(message); +}; + + +/** + * Emulates the HTML 5 addEventListener interface. Works only for the + * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedChromeRuntimePort_.prototype.addEventListener = + function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message' || name == 'onmessage') { + this.port_.onMessage.addListener(function(message) { + // Emulate a minimal MessageEvent object + handler({'data': message}); + }); + } else { + console.error('WrappedChromeRuntimePort only supports onMessage'); + } +}; + +/** + * Wrap the Authenticator app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedAuthenticatorPort_ = function() { + this.requestId_ = -1; + this.requestObject_ = null; +} + +/** + * Launch the Authenticator intent. + * @param {Object} message + */ +u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) { + var intentUrl = + u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + + ';S.request=' + encodeURIComponent(JSON.stringify(message)) + + ';end'; + document.location = intentUrl; +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedAuthenticatorPort_.prototype.getPortType = function() { + return "WrappedAuthenticatorPort_"; +}; + + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message') { + var self = this; + /* Register a callback to that executes when + * chrome injects the response. */ + window.addEventListener( + 'message', self.onRequestUpdate_.bind(self, handler), false); + } else { + console.error('WrappedAuthenticatorPort only supports message'); + } +}; + +/** + * Callback invoked when a response is received from the Authenticator. + * @param function({data: Object}) callback + * @param {Object} message message Object + */ +u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = + function(callback, message) { + var messageObject = JSON.parse(message.data); + var intentUrl = messageObject['intentURL']; + + var errorCode = messageObject['errorCode']; + var responseObject = null; + if (messageObject.hasOwnProperty('data')) { + responseObject = /** @type {Object} */ ( + JSON.parse(messageObject['data'])); + } + + callback({'data': responseObject}); +}; + +/** + * Base URL for intents to Authenticator. + * @const + * @private + */ +u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = + 'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE'; + +/** + * Wrap the iOS client app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedIosPort_ = function() {}; + +/** + * Launch the iOS client app request + * @param {Object} message + */ +u2f.WrappedIosPort_.prototype.postMessage = function(message) { + var str = JSON.stringify(message); + var url = "u2f://auth?" + encodeURI(str); + location.replace(url); +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedIosPort_.prototype.getPortType = function() { + return "WrappedIosPort_"; +}; + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedIosPort_.prototype.addEventListener = function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name !== 'message') { + console.error('WrappedIosPort only supports message'); + } +}; + +/** + * Sets up an embedded trampoline iframe, sourced from the extension. + * @param {function(MessagePort)} callback + * @private + */ +u2f.getIframePort_ = function(callback) { + // Create the iframe + var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; + var iframe = document.createElement('iframe'); + iframe.src = iframeOrigin + '/u2f-comms.html'; + iframe.setAttribute('style', 'display:none'); + document.body.appendChild(iframe); + + var channel = new MessageChannel(); + var ready = function(message) { + if (message.data == 'ready') { + channel.port1.removeEventListener('message', ready); + callback(channel.port1); + } else { + console.error('First event on iframe port was not "ready"'); + } + }; + channel.port1.addEventListener('message', ready); + channel.port1.start(); + + iframe.addEventListener('load', function() { + // Deliver the port to the iframe and initialize + iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); + }); +}; + + +//High-level JS API + +/** + * Default extension response timeout in seconds. + * @const + */ +u2f.EXTENSION_TIMEOUT_SEC = 30; + +/** + * A singleton instance for a MessagePort to the extension. + * @type {MessagePort|u2f.WrappedChromeRuntimePort_} + * @private + */ +u2f.port_ = null; + +/** + * Callbacks waiting for a port + * @type {Array} + * @private + */ +u2f.waitingForPort_ = []; + +/** + * A counter for requestIds. + * @type {number} + * @private + */ +u2f.reqCounter_ = 0; + +/** + * A map from requestIds to client callbacks + * @type {Object.} + * @private + */ +u2f.callbackMap_ = {}; + +/** + * Creates or retrieves the MessagePort singleton to use. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + * @private + */ +u2f.getPortSingleton_ = function(callback) { + if (u2f.port_) { + callback(u2f.port_); + } else { + if (u2f.waitingForPort_.length == 0) { + u2f.getMessagePort(function(port) { + u2f.port_ = port; + u2f.port_.addEventListener('message', + /** @type {function(Event)} */ (u2f.responseHandler_)); + + // Careful, here be async callbacks. Maybe. + while (u2f.waitingForPort_.length) + u2f.waitingForPort_.shift()(u2f.port_); + }); + } + u2f.waitingForPort_.push(callback); + } +}; + +/** + * Handles response messages from the extension. + * @param {MessageEvent.} message + * @private + */ +u2f.responseHandler_ = function(message) { + var response = message.data; + var reqId = response['requestId']; + if (!reqId || !u2f.callbackMap_[reqId]) { + console.error('Unknown or missing requestId in response.'); + return; + } + var cb = u2f.callbackMap_[reqId]; + delete u2f.callbackMap_[reqId]; + cb(response['responseData']); +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the sign request. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sign = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual sign request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version']; + console.log("Extension JS API Version: ", js_api_version); + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual sign request in the supported API version. + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendSignRequest = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatSignRequest_(appId, challenge, registeredKeys, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the register request. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.register = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual register request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0: response['js_api_version']; + console.log("Extension JS API Version: ", js_api_version); + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual register request in the supported API version. + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendRegisterRequest = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatRegisterRequest_( + appId, registeredKeys, registerRequests, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + + +/** + * Dispatches a message to the extension to find out the supported + * JS API version. + * If the user is on a mobile phone and is thus using Google Authenticator instead + * of the Chrome extension, don't send the request and simply return 0. + * @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.getApiVersion = function(callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + // If we are using Android Google Authenticator or iOS client app, + // do not fire an intent to ask which JS API version to use. + if (port.getPortType) { + var apiVersion; + switch (port.getPortType()) { + case 'WrappedIosPort_': + case 'WrappedAuthenticatorPort_': + apiVersion = 1.1; + break; + + default: + apiVersion = 0; + break; + } + callback({ 'js_api_version': apiVersion }); + return; + } + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var req = { + type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST, + timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC), + requestId: reqId + }; + port.postMessage(req); + }); +}; + diff --git a/themes/black/client/test/Notifier.test.ts b/themes/black/client/test/Notifier.test.ts new file mode 100644 index 00000000..70bfea14 --- /dev/null +++ b/themes/black/client/test/Notifier.test.ts @@ -0,0 +1,71 @@ + +import Assert = require("assert"); +import Sinon = require("sinon"); +import JQueryMock = require("./mocks/jquery"); + +import { Notifier } from "../src/lib/Notifier"; + +describe("test notifier", function() { + const SELECTOR = "dummy-selector"; + const MESSAGE = "This is a message"; + let jqueryMock: { jquery: JQueryMock.JQueryMock, element: JQueryMock.JQueryElementsMock }; + let clock: any; + + beforeEach(function() { + jqueryMock = JQueryMock.JQueryMock(); + clock = Sinon.useFakeTimers(); + }); + + afterEach(function() { + clock.restore(); + }); + + function should_fade_in_and_out_on_notification(notificationType: string): void { + const delayReturn = { + fadeOut: Sinon.stub() + }; + + jqueryMock.element.fadeIn.yields(); + + function onFadedInCallback() { + Assert(jqueryMock.element.fadeIn.calledOnce); + Assert(jqueryMock.element.addClass.calledWith(notificationType)); + Assert(!jqueryMock.element.removeClass.calledWith(notificationType)); + clock.tick(10 * 1000); + } + + function onFadedOutCallback() { + Assert(jqueryMock.element.removeClass.calledWith(notificationType)); + Assert(jqueryMock.element.fadeOut.calledOnce); + } + + const notifier = new Notifier(SELECTOR, jqueryMock.jquery as any); + + // Call the method by its name... Bad but allows code reuse. + (notifier as any)[notificationType](MESSAGE, { + onFadedIn: onFadedInCallback, + onFadedOut: onFadedOutCallback + }); + + clock.tick(510); + + Assert(jqueryMock.element.fadeIn.calledOnce); + } + + + it("should fade in and fade out an error message", function() { + should_fade_in_and_out_on_notification("error"); + }); + + it("should fade in and fade out an info message", function() { + should_fade_in_and_out_on_notification("info"); + }); + + it("should fade in and fade out an warning message", function() { + should_fade_in_and_out_on_notification("warning"); + }); + + it("should fade in and fade out an success message", function() { + should_fade_in_and_out_on_notification("success"); + }); +}); \ No newline at end of file diff --git a/themes/black/client/test/firstfactor/FirstFactorValidator.test.ts b/themes/black/client/test/firstfactor/FirstFactorValidator.test.ts new file mode 100644 index 00000000..ac835327 --- /dev/null +++ b/themes/black/client/test/firstfactor/FirstFactorValidator.test.ts @@ -0,0 +1,44 @@ + +import FirstFactorValidator = require("../../src/lib/firstfactor/FirstFactorValidator"); +import JQueryMock = require("../mocks/jquery"); +import BluebirdPromise = require("bluebird"); +import Assert = require("assert"); + +describe("test FirstFactorValidator", function () { + it("should validate first factor successfully", () => { + const postPromise = JQueryMock.JQueryDeferredMock(); + postPromise.done.yields({ redirect: "http://redirect" }); + postPromise.done.returns(postPromise); + + const jqueryMock = JQueryMock.JQueryMock(); + jqueryMock.jquery.ajax.returns(postPromise); + + return FirstFactorValidator.validate("username", "password", "http://redirect", jqueryMock.jquery as any); + }); + + function should_fail_first_factor_validation(errorMessage: string) { + const xhr = { + status: 401 + }; + const postPromise = JQueryMock.JQueryDeferredMock(); + postPromise.fail.yields(xhr, errorMessage); + postPromise.done.returns(postPromise); + + const jqueryMock = JQueryMock.JQueryMock(); + jqueryMock.jquery.ajax.returns(postPromise); + + return FirstFactorValidator.validate("username", "password", "http://redirect", jqueryMock.jquery as any) + .then(function () { + return BluebirdPromise.reject(new Error("First factor validation successfully finished while it should have not.")); + }, function (err: Error) { + Assert.equal(errorMessage, err.message); + return BluebirdPromise.resolve(); + }); + } + + describe("should fail first factor validation", () => { + it("should fail with error", () => { + return should_fail_first_factor_validation("Authentication failed. Please check your credentials."); + }); + }); +}); \ No newline at end of file diff --git a/themes/black/client/test/mocks/NotifierStub.ts b/themes/black/client/test/mocks/NotifierStub.ts new file mode 100644 index 00000000..9c268d66 --- /dev/null +++ b/themes/black/client/test/mocks/NotifierStub.ts @@ -0,0 +1,33 @@ + +import Sinon = require("sinon"); +import { INotifier } from "../../src/lib/INotifier"; + +export class NotifierStub implements INotifier { + successStub: Sinon.SinonStub; + errorStub: Sinon.SinonStub; + warnStub: Sinon.SinonStub; + infoStub: Sinon.SinonStub; + + constructor() { + this.successStub = Sinon.stub(); + this.errorStub = Sinon.stub(); + this.warnStub = Sinon.stub(); + this.infoStub = Sinon.stub(); + } + + success(msg: string) { + this.successStub(); + } + + error(msg: string) { + this.errorStub(); + } + + warning(msg: string) { + this.warnStub(); + } + + info(msg: string) { + this.infoStub(); + } +} \ No newline at end of file diff --git a/themes/black/client/test/mocks/jquery.ts b/themes/black/client/test/mocks/jquery.ts new file mode 100644 index 00000000..273f9086 --- /dev/null +++ b/themes/black/client/test/mocks/jquery.ts @@ -0,0 +1,59 @@ + +import sinon = require("sinon"); +import jquery = require("jquery"); + + +export interface JQueryMock extends sinon.SinonStub { + get: sinon.SinonStub; + post: sinon.SinonStub; + ajax: sinon.SinonStub; + notify: sinon.SinonStub; +} + +export interface JQueryElementsMock { + ready: sinon.SinonStub; + show: sinon.SinonStub; + hide: sinon.SinonStub; + html: sinon.SinonStub; + addClass: sinon.SinonStub; + removeClass: sinon.SinonStub; + fadeIn: sinon.SinonStub; + fadeOut: sinon.SinonStub; + on: sinon.SinonStub; +} + +export interface JQueryDeferredMock { + done: sinon.SinonStub; + fail: sinon.SinonStub; +} + +export function JQueryMock(): { jquery: JQueryMock, element: JQueryElementsMock } { + const jquery = sinon.stub() as any; + const jqueryInstance: JQueryElementsMock = { + ready: sinon.stub(), + show: sinon.stub(), + hide: sinon.stub(), + html: sinon.stub(), + addClass: sinon.stub(), + removeClass: sinon.stub(), + fadeIn: sinon.stub(), + fadeOut: sinon.stub(), + on: sinon.stub() + }; + jquery.ajax = sinon.stub(); + jquery.get = sinon.stub(); + jquery.post = sinon.stub(); + jquery.notify = sinon.stub(); + jquery.returns(jqueryInstance); + return { + jquery: jquery, + element: jqueryInstance + }; +} + +export function JQueryDeferredMock(): JQueryDeferredMock { + return { + done: sinon.stub(), + fail: sinon.stub() + }; +} diff --git a/themes/black/client/test/mocks/u2f-api.ts b/themes/black/client/test/mocks/u2f-api.ts new file mode 100644 index 00000000..d123f6a9 --- /dev/null +++ b/themes/black/client/test/mocks/u2f-api.ts @@ -0,0 +1,14 @@ + +import sinon = require("sinon"); + +export interface U2FApiMock { + sign: sinon.SinonStub; + register: sinon.SinonStub; +} + +export function U2FApiMock(): U2FApiMock { + return { + sign: sinon.stub(), + register: sinon.stub() + }; +} \ No newline at end of file diff --git a/themes/black/client/test/secondfactor/TOTPValidator.test.ts b/themes/black/client/test/secondfactor/TOTPValidator.test.ts new file mode 100644 index 00000000..5dd6f15c --- /dev/null +++ b/themes/black/client/test/secondfactor/TOTPValidator.test.ts @@ -0,0 +1,37 @@ + +import TOTPValidator = require("../../src/lib/secondfactor/TOTPValidator"); +import JQueryMock = require("../mocks/jquery"); +import BluebirdPromise = require("bluebird"); +import Assert = require("assert"); + +describe("test TOTPValidator", function () { + it("should initiate an identity check successfully", () => { + const postPromise = JQueryMock.JQueryDeferredMock(); + postPromise.done.yields({ redirect: "https://home.test.url" }); + postPromise.done.returns(postPromise); + + const jqueryMock = JQueryMock.JQueryMock(); + jqueryMock.jquery.ajax.returns(postPromise); + + return TOTPValidator.validate("totp_token", jqueryMock.jquery as any); + }); + + it("should fail validating TOTP token", () => { + const errorMessage = "Error while validating TOTP token"; + + const postPromise = JQueryMock.JQueryDeferredMock(); + postPromise.fail.yields(undefined, errorMessage); + postPromise.done.returns(postPromise); + + const jqueryMock = JQueryMock.JQueryMock(); + jqueryMock.jquery.ajax.returns(postPromise); + + return TOTPValidator.validate("totp_token", jqueryMock.jquery as any) + .then(function () { + return BluebirdPromise.reject(new Error("Registration successfully finished while it should have not.")); + }, function (err: Error) { + Assert.equal(errorMessage, err.message); + return BluebirdPromise.resolve(); + }); + }); +}); \ No newline at end of file diff --git a/themes/black/client/test/totp-register/totp-register.test.ts b/themes/black/client/test/totp-register/totp-register.test.ts new file mode 100644 index 00000000..86fc455a --- /dev/null +++ b/themes/black/client/test/totp-register/totp-register.test.ts @@ -0,0 +1,31 @@ + +import sinon = require("sinon"); +import assert = require("assert"); + +import UISelector = require("../../src/lib/totp-register/ui-selector"); +import TOTPRegister = require("../../src/lib/totp-register/totp-register"); + +describe("test totp-register", function() { + let jqueryMock: any; + let windowMock: any; + before(function() { + jqueryMock = sinon.stub(); + windowMock = { + QRCode: sinon.spy() + }; + }); + + it("should create qrcode in page", function() { + const mock = { + text: sinon.stub(), + empty: sinon.stub(), + get: sinon.stub() + }; + jqueryMock.withArgs(UISelector.QRCODE_ID_SELECTOR).returns(mock); + + TOTPRegister.default(windowMock, jqueryMock); + + assert(mock.text.calledOnce); + assert(mock.empty.calledOnce); + }); +}); \ No newline at end of file diff --git a/themes/black/client/tsconfig.json b/themes/black/client/tsconfig.json new file mode 100644 index 00000000..0bb4d62f --- /dev/null +++ b/themes/black/client/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es6", + "moduleResolution": "node", + "noImplicitAny": true, + "sourceMap": true, + "removeComments": true, + "outDir": "../dist", + "baseUrl": ".", + "paths": { + "*": [ + "./types/*", + "../shared/types/*" + ] + } + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "test/**/*" + ] +} diff --git a/themes/black/client/tslint.json b/themes/black/client/tslint.json new file mode 100644 index 00000000..c2c1b750 --- /dev/null +++ b/themes/black/client/tslint.json @@ -0,0 +1,60 @@ +{ + "rules": { + "class-name": true, + "comment-format": [ + true, + "check-space" + ], + "indent": [ + true, + "spaces" + ], + "one-line": [ + true, + "check-open-brace", + "check-whitespace" + ], + "no-var-keyword": true, + "quotemark": [ + true, + "double", + "avoid-escape" + ], + "semicolon": [ + true, + "always", + "ignore-bound-class-methods" + ], + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-operator", + "check-module", + "check-separator", + "check-type" + ], + "typedef-whitespace": [ + true, + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + }, + { + "call-signature": "onespace", + "index-signature": "onespace", + "parameter": "onespace", + "property-declaration": "onespace", + "variable-declaration": "onespace" + } + ], + "no-internal-module": true, + "no-trailing-whitespace": true, + "no-null-keyword": true, + "prefer-const": true, + "jsdoc-format": true + } +} diff --git a/themes/black/server/.directory b/themes/black/server/.directory new file mode 100644 index 00000000..b7754766 --- /dev/null +++ b/themes/black/server/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,17,20,58,20 +Version=3 +ViewMode=1 diff --git a/themes/black/server/src/index.ts b/themes/black/server/src/index.ts new file mode 100755 index 00000000..fcbf4d02 --- /dev/null +++ b/themes/black/server/src/index.ts @@ -0,0 +1,28 @@ +#! /usr/bin/env node + +import Server from "./lib/Server"; +import { GlobalDependencies } from "../types/Dependencies"; +import YAML = require("yamljs"); + +const configurationFilepath = process.argv[2]; +if (!configurationFilepath) { + console.log("No config file has been provided."); + console.log("Usage: authelia "); + process.exit(0); +} + +const yamlContent = YAML.load(configurationFilepath); + +const deps: GlobalDependencies = { + u2f: require("u2f"), + ldapjs: require("ldapjs"), + session: require("express-session"), + winston: require("winston"), + speakeasy: require("speakeasy"), + nedb: require("nedb"), + ConnectRedis: require("connect-redis"), + Redis: require("redis") +}; + +const server = new Server(deps); +server.start(yamlContent, deps); diff --git a/themes/black/server/src/lib/.directory b/themes/black/server/src/lib/.directory new file mode 100644 index 00000000..006b379a --- /dev/null +++ b/themes/black/server/src/lib/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,17,20,59,13 +Version=3 +ViewMode=1 diff --git a/themes/black/server/src/lib/AuthenticationSessionHandler.ts b/themes/black/server/src/lib/AuthenticationSessionHandler.ts new file mode 100644 index 00000000..57361bf8 --- /dev/null +++ b/themes/black/server/src/lib/AuthenticationSessionHandler.ts @@ -0,0 +1,45 @@ + + +import express = require("express"); +import U2f = require("u2f"); +import BluebirdPromise = require("bluebird"); +import { AuthenticationSession } from "../../types/AuthenticationSession"; +import { IRequestLogger } from "./logging/IRequestLogger"; +import { Level } from "./authentication/Level"; + +const INITIAL_AUTHENTICATION_SESSION: AuthenticationSession = { + keep_me_logged_in: false, + authentication_level: Level.NOT_AUTHENTICATED, + last_activity_datetime: undefined, + userid: undefined, + email: undefined, + groups: [], + register_request: undefined, + sign_request: undefined, + identity_check: undefined, + redirect: undefined +}; + +export class AuthenticationSessionHandler { + static reset(req: express.Request): void { + req.session.auth = Object.assign({}, INITIAL_AUTHENTICATION_SESSION, {}); + + // Initialize last activity with current time + req.session.auth.last_activity_datetime = new Date().getTime(); + } + + static get(req: express.Request, logger: IRequestLogger): AuthenticationSession { + if (!req.session) { + const errorMsg = "Something is wrong with session cookies. Please check Redis is running and Authelia can connect to it."; + logger.error(req, errorMsg); + throw new Error(errorMsg); + } + + if (!req.session.auth) { + logger.debug(req, "Authentication session %s was undefined. Resetting.", req.sessionID); + AuthenticationSessionHandler.reset(req); + } + + return req.session.auth; + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/ErrorReplies.ts b/themes/black/server/src/lib/ErrorReplies.ts new file mode 100644 index 00000000..f1c5f4fd --- /dev/null +++ b/themes/black/server/src/lib/ErrorReplies.ts @@ -0,0 +1,49 @@ +import express = require("express"); +import BluebirdPromise = require("bluebird"); +import { IRequestLogger } from "./logging/IRequestLogger"; + +function replyWithError(req: express.Request, res: express.Response, + code: number, logger: IRequestLogger, body?: Object): (err: Error) => void { + return function (err: Error): void { + if (req.originalUrl.startsWith("/api/") || code == 200) { + logger.error(req, "Reply with error %d: %s", code, err.message); + logger.debug(req, "%s", err.stack); + res.status(code); + res.send(body); + } + else { + logger.error(req, "Redirect to error %d: %s", code, err.message); + logger.debug(req, "%s", err.stack); + res.redirect("/error/" + code); + } + }; +} + +export function redirectTo(redirectUrl: string, req: express.Request, + res: express.Response, logger: IRequestLogger) { + return function(err: Error) { + logger.error(req, "Error: %s", err.message); + logger.debug(req, "Redirecting to %s", redirectUrl); + res.redirect(redirectUrl); + }; +} + +export function replyWithError400(req: express.Request, + res: express.Response, logger: IRequestLogger) { + return replyWithError(req, res, 400, logger); +} + +export function replyWithError401(req: express.Request, + res: express.Response, logger: IRequestLogger) { + return replyWithError(req, res, 401, logger); +} + +export function replyWithError403(req: express.Request, + res: express.Response, logger: IRequestLogger) { + return replyWithError(req, res, 403, logger); +} + +export function replyWithError200(req: express.Request, + res: express.Response, logger: IRequestLogger, message: string) { + return replyWithError(req, res, 200, logger, { error: message }); +} \ No newline at end of file diff --git a/themes/black/server/src/lib/Exceptions.ts b/themes/black/server/src/lib/Exceptions.ts new file mode 100644 index 00000000..83fa4eb6 --- /dev/null +++ b/themes/black/server/src/lib/Exceptions.ts @@ -0,0 +1,88 @@ + +export class LdapSearchError extends Error { + constructor(message?: string) { + super(message); + this.name = "LdapSearchError"; + (Object).setPrototypeOf(this, LdapSearchError.prototype); + } +} + +export class LdapBindError extends Error { + constructor(message?: string) { + super(message); + this.name = "LdapBindError"; + (Object).setPrototypeOf(this, LdapBindError.prototype); + } +} + +export class LdapError extends Error { + constructor(message?: string) { + super(message); + this.name = "LdapError"; + (Object).setPrototypeOf(this, LdapError.prototype); + } +} + +export class IdentityError extends Error { + constructor(message?: string) { + super(message); + this.name = "IdentityError"; + (Object).setPrototypeOf(this, IdentityError.prototype); + } +} + +export class AccessDeniedError extends Error { + constructor(message?: string) { + super(message); + this.name = "AccessDeniedError"; + (Object).setPrototypeOf(this, AccessDeniedError.prototype); + } +} + +export class AuthenticationRegulationError extends Error { + constructor(message?: string) { + super(message); + this.name = "AuthenticationRegulationError"; + (Object).setPrototypeOf(this, AuthenticationRegulationError.prototype); + } +} + +export class InvalidTOTPError extends Error { + constructor(message?: string) { + super(message); + this.name = "InvalidTOTPError"; + (Object).setPrototypeOf(this, InvalidTOTPError.prototype); + } +} + +export class NotAuthenticatedError extends Error { + constructor(message?: string) { + super(message); + this.name = "NotAuthenticatedError"; + (Object).setPrototypeOf(this, NotAuthenticatedError.prototype); + } +} + +export class NotAuthorizedError extends Error { + constructor(message?: string) { + super(message); + this.name = "NotAuthanticatedError"; + (Object).setPrototypeOf(this, NotAuthorizedError.prototype); + } +} + +export class FirstFactorValidationError extends Error { + constructor(message?: string) { + super(message); + this.name = "FirstFactorValidationError"; + (Object).setPrototypeOf(this, FirstFactorValidationError.prototype); + } +} + +export class SecondFactorValidationError extends Error { + constructor(message?: string) { + super(message); + this.name = "SecondFactorValidationError"; + (Object).setPrototypeOf(this, FirstFactorValidationError.prototype); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/FirstFactorValidator.ts b/themes/black/server/src/lib/FirstFactorValidator.ts new file mode 100644 index 00000000..23106000 --- /dev/null +++ b/themes/black/server/src/lib/FirstFactorValidator.ts @@ -0,0 +1,20 @@ + +import BluebirdPromise = require("bluebird"); +import express = require("express"); +import objectPath = require("object-path"); +import Exceptions = require("./Exceptions"); +import { AuthenticationSessionHandler } from "./AuthenticationSessionHandler"; +import { IRequestLogger } from "./logging/IRequestLogger"; +import { Level } from "./authentication/Level"; + +export function validate(req: express.Request, logger: IRequestLogger): BluebirdPromise { + return new BluebirdPromise(function (resolve, reject) { + const authSession = AuthenticationSessionHandler.get(req, logger); + + if (!authSession.userid || authSession.authentication_level < Level.ONE_FACTOR) + return reject(new Exceptions.FirstFactorValidationError( + "First factor has not been validated yet.")); + + resolve(); + }); +} \ No newline at end of file diff --git a/themes/black/server/src/lib/IdentityCheckMiddleware.spec.ts b/themes/black/server/src/lib/IdentityCheckMiddleware.spec.ts new file mode 100644 index 00000000..842ed6bc --- /dev/null +++ b/themes/black/server/src/lib/IdentityCheckMiddleware.spec.ts @@ -0,0 +1,176 @@ + +import sinon = require("sinon"); +import IdentityValidator = require("./IdentityCheckMiddleware"); +import { AuthenticationSessionHandler } + from "./AuthenticationSessionHandler"; +import { AuthenticationSession } from "../../types/AuthenticationSession"; +import { UserDataStore } from "./storage/UserDataStore"; +import exceptions = require("./Exceptions"); +import { ServerVariables } from "./ServerVariables"; +import Assert = require("assert"); +import express = require("express"); +import BluebirdPromise = require("bluebird"); +import ExpressMock = require("./stubs/express.spec"); +import NotifierMock = require("./notifiers/NotifierStub.spec"); +import { IdentityValidableStub } from "./IdentityValidableStub.spec"; +import { RequestLoggerStub } from "./logging/RequestLoggerStub.spec"; +import { ServerVariablesMock, ServerVariablesMockBuilder } + from "./ServerVariablesMockBuilder.spec"; +import { PRE_VALIDATION_TEMPLATE } + from "./IdentityCheckPreValidationTemplate"; + + +describe("IdentityCheckMiddleware", function () { + let req: ExpressMock.RequestMock; + let res: ExpressMock.ResponseMock; + let app: express.Application; + let app_get: sinon.SinonStub; + let app_post: sinon.SinonStub; + let identityValidable: IdentityValidableStub; + let mocks: ServerVariablesMock; + let vars: ServerVariables; + + beforeEach(function () { + const s = ServerVariablesMockBuilder.build(); + mocks = s.mocks; + vars = s.variables; + + req = ExpressMock.RequestMock(); + res = ExpressMock.ResponseMock(); + + req.headers = {}; + req.originalUrl = "/non-api/xxx"; + req.session = {}; + + req.query = {}; + req.app = {}; + + identityValidable = new IdentityValidableStub(); + + mocks.notifier.notifyStub.returns(BluebirdPromise.resolve()); + mocks.userDataStore.produceIdentityValidationTokenStub + .returns(BluebirdPromise.resolve()); + mocks.userDataStore.consumeIdentityValidationTokenStub + .returns(BluebirdPromise.resolve({ userId: "user" })); + + app = express(); + app_get = sinon.stub(app, "get"); + app_post = sinon.stub(app, "post"); + }); + + afterEach(function () { + app_get.restore(); + app_post.restore(); + }); + + describe("test start GET", function () { + it("should redirect to error 401 if pre validation initialization \ +throws a first factor error", function () { + identityValidable.preValidationInitStub.returns(BluebirdPromise.reject( + new exceptions.FirstFactorValidationError( + "Error during prevalidation"))); + const callback = IdentityValidator.get_start_validation( + identityValidable, "/endpoint", vars); + + return callback(req as any, res as any, undefined) + .then(() => { + Assert(res.redirect.calledWith("/error/401")); + }); + }); + + // In that case we answer with 200 to avoid user enumeration. + it("should send 200 if email is missing in provided identity", function () { + const identity = { userid: "abc" }; + + identityValidable.preValidationInitStub + .returns(BluebirdPromise.resolve(identity)); + const callback = IdentityValidator + .get_start_validation(identityValidable, "/endpoint", vars); + + return callback(req as any, res as any, undefined) + .then(function () { + Assert(identityValidable.preValidationResponseStub.called); + }); + }); + + // In that case we answer with 200 to avoid user enumeration. + it("should send 200 if userid is missing in provided identity", + function () { + const endpoint = "/protected"; + const identity = { email: "abc@example.com" }; + + identityValidable.preValidationInitStub + .returns(BluebirdPromise.resolve(identity)); + const callback = IdentityValidator + .get_start_validation(identityValidable, "/endpoint", vars); + + return callback(req as any, res as any, undefined) + .then(function () { + Assert(identityValidable.preValidationResponseStub.called); + }); + }); + + it("should issue a token, send an email and return 204", function () { + const endpoint = "/protected"; + const identity = { userid: "user", email: "abc@example.com" }; + req.get = sinon.stub().withArgs("Host").returns("localhost"); + + identityValidable.preValidationInitStub + .returns(BluebirdPromise.resolve(identity)); + const callback = IdentityValidator + .get_start_validation(identityValidable, "/finish_endpoint", vars); + + return callback(req as any, res as any, undefined) + .then(function () { + Assert(mocks.notifier.notifyStub.calledOnce); + Assert(mocks.userDataStore.produceIdentityValidationTokenStub + .calledOnce); + Assert.equal(mocks.userDataStore.produceIdentityValidationTokenStub + .getCall(0).args[0], "user"); + Assert.equal(mocks.userDataStore.produceIdentityValidationTokenStub + .getCall(0).args[3], 240000); + }); + }); + }); + + + + describe("test finish GET", function () { + it("should send 401 if no identity_token is provided", () => { + const callback = IdentityValidator + .get_finish_validation(identityValidable, vars); + + return callback(req as any, res as any, undefined) + .then(function () { + Assert(res.redirect.calledWith("/error/401")); + }); + }); + + it("should call postValidation if identity_token is provided and still \ +valid", function () { + req.query.identity_token = "token"; + + const callback = IdentityValidator + .get_finish_validation(identityValidable, vars); + return callback(req as any, res as any, undefined); + }); + + it("should return 401 if identity_token is provided but invalid", + function () { + req.query.identity_token = "token"; + + identityValidable.postValidationInitStub + .returns(BluebirdPromise.resolve()); + mocks.userDataStore.consumeIdentityValidationTokenStub.reset(); + mocks.userDataStore.consumeIdentityValidationTokenStub + .returns(BluebirdPromise.reject(new Error("Invalid token"))); + + const callback = IdentityValidator + .get_finish_validation(identityValidable, vars); + return callback(req as any, res as any, undefined) + .then(() => { + Assert(res.redirect.calledWith("/error/401")); + }); + }); + }); +}); diff --git a/themes/black/server/src/lib/IdentityCheckMiddleware.ts b/themes/black/server/src/lib/IdentityCheckMiddleware.ts new file mode 100644 index 00000000..e72ea4db --- /dev/null +++ b/themes/black/server/src/lib/IdentityCheckMiddleware.ts @@ -0,0 +1,138 @@ +import objectPath = require("object-path"); +import randomstring = require("randomstring"); +import BluebirdPromise = require("bluebird"); +import util = require("util"); +import Exceptions = require("./Exceptions"); +import fs = require("fs"); +import ejs = require("ejs"); +import { IUserDataStore } from "./storage/IUserDataStore"; +import Express = require("express"); +import ErrorReplies = require("./ErrorReplies"); +import { AuthenticationSessionHandler } from "./AuthenticationSessionHandler"; +import { AuthenticationSession } from "../../types/AuthenticationSession"; +import { ServerVariables } from "./ServerVariables"; +import { IdentityValidable } from "./IdentityValidable"; + +import Identity = require("../../types/Identity"); +import { IdentityValidationDocument } + from "./storage/IdentityValidationDocument"; + +const filePath = __dirname + "/../resources/email-template.ejs"; +const email_template = fs.readFileSync(filePath, "utf8"); + +function createAndSaveToken(userid: string, challenge: string, + userDataStore: IUserDataStore): BluebirdPromise { + + const five_minutes = 4 * 60 * 1000; + const token = randomstring.generate({ length: 64 }); + const that = this; + + return userDataStore.produceIdentityValidationToken(userid, token, challenge, + five_minutes) + .then(function () { + return BluebirdPromise.resolve(token); + }); +} + +function consumeToken(token: string, challenge: string, + userDataStore: IUserDataStore) + : BluebirdPromise { + return userDataStore.consumeIdentityValidationToken(token, challenge); +} + +export function register(app: Express.Application, + pre_validation_endpoint: string, + post_validation_endpoint: string, + handler: IdentityValidable, + vars: ServerVariables) { + + app.get(pre_validation_endpoint, + get_start_validation(handler, post_validation_endpoint, vars)); + app.get(post_validation_endpoint, + get_finish_validation(handler, vars)); +} + +function checkIdentityToken(req: Express.Request, identityToken: string) + : BluebirdPromise { + if (!identityToken) + return BluebirdPromise.reject( + new Exceptions.AccessDeniedError("No identity token provided")); + return BluebirdPromise.resolve(); +} + +export function get_finish_validation(handler: IdentityValidable, + vars: ServerVariables) + : Express.RequestHandler { + + return function (req: Express.Request, res: Express.Response) + : BluebirdPromise { + + let authSession: AuthenticationSession; + const identityToken = objectPath.get( + req, "query.identity_token"); + vars.logger.debug(req, "Identity token provided is %s", identityToken); + + return checkIdentityToken(req, identityToken) + .then(() => { + authSession = AuthenticationSessionHandler.get(req, vars.logger); + return handler.postValidationInit(req); + }) + .then(() => { + return consumeToken(identityToken, handler.challenge(), + vars.userDataStore); + }) + .then((doc: IdentityValidationDocument) => { + authSession.identity_check = { + challenge: handler.challenge(), + userid: doc.userId + }; + handler.postValidationResponse(req, res); + return BluebirdPromise.resolve(); + }) + .catch(ErrorReplies.replyWithError401(req, res, vars.logger)); + }; +} + +export function get_start_validation(handler: IdentityValidable, + postValidationEndpoint: string, + vars: ServerVariables) + : Express.RequestHandler { + return function (req: Express.Request, res: Express.Response) + : BluebirdPromise { + let identity: Identity.Identity; + + return handler.preValidationInit(req) + .then((id: Identity.Identity) => { + identity = id; + const email = identity.email; + const userid = identity.userid; + vars.logger.info(req, "Start identity validation of user \"%s\"", + userid); + + if (!(email && userid)) + return BluebirdPromise.reject(new Exceptions.IdentityError( + "Missing user id or email address")); + + return createAndSaveToken(userid, handler.challenge(), + vars.userDataStore); + }) + .then((token) => { + const host = req.get("Host"); + const link_url = util.format("https://%s%s?identity_token=%s", host, + postValidationEndpoint, token); + vars.logger.info(req, "Notification sent to user \"%s\"", + identity.userid); + return vars.notifier.notify(identity.email, handler.mailSubject(), + link_url); + }) + .then(() => { + handler.preValidationResponse(req, res); + return BluebirdPromise.resolve(); + }) + .catch(Exceptions.IdentityError, (err: Error) => { + handler.preValidationResponse(req, res); + return BluebirdPromise.resolve(); + }) + .catch(ErrorReplies.replyWithError401(req, res, vars.logger)); + }; +} diff --git a/themes/black/server/src/lib/IdentityCheckPreValidationTemplate.ts b/themes/black/server/src/lib/IdentityCheckPreValidationTemplate.ts new file mode 100644 index 00000000..0161ce40 --- /dev/null +++ b/themes/black/server/src/lib/IdentityCheckPreValidationTemplate.ts @@ -0,0 +1,3 @@ + + +export const PRE_VALIDATION_TEMPLATE = "need-identity-validation"; \ No newline at end of file diff --git a/themes/black/server/src/lib/IdentityValidable.ts b/themes/black/server/src/lib/IdentityValidable.ts new file mode 100644 index 00000000..075580c9 --- /dev/null +++ b/themes/black/server/src/lib/IdentityValidable.ts @@ -0,0 +1,19 @@ +import Bluebird = require("bluebird"); +import Identity = require("../../types/Identity"); + +// IdentityValidator allows user to go through a identity validation process +// in two steps: +// - Request an operation to be performed (password reset, registration). +// - Confirm operation with email. + +export interface IdentityValidable { + challenge(): string; + preValidationInit(req: Express.Request): Bluebird; + postValidationInit(req: Express.Request): Bluebird; + + // Serves a page after identity check request + preValidationResponse(req: Express.Request, res: Express.Response): void; + // Serves the page if identity validated + postValidationResponse(req: Express.Request, res: Express.Response): void; + mailSubject(): string; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/IdentityValidableStub.spec.ts b/themes/black/server/src/lib/IdentityValidableStub.spec.ts new file mode 100644 index 00000000..20a97714 --- /dev/null +++ b/themes/black/server/src/lib/IdentityValidableStub.spec.ts @@ -0,0 +1,52 @@ + +import Sinon = require("sinon"); +import { IdentityValidable } from "./IdentityValidable"; +import express = require("express"); +import Bluebird = require("bluebird"); +import { Identity } from "../../types/Identity"; + + +export class IdentityValidableStub implements IdentityValidable { + challengeStub: Sinon.SinonStub; + preValidationInitStub: Sinon.SinonStub; + postValidationInitStub: Sinon.SinonStub; + preValidationResponseStub: Sinon.SinonStub; + postValidationResponseStub: Sinon.SinonStub; + mailSubjectStub: Sinon.SinonStub; + + constructor() { + this.challengeStub = Sinon.stub(); + + this.preValidationInitStub = Sinon.stub(); + this.postValidationInitStub = Sinon.stub(); + + this.preValidationResponseStub = Sinon.stub(); + this.postValidationResponseStub = Sinon.stub(); + + this.mailSubjectStub = Sinon.stub(); + } + + challenge(): string { + return this.challengeStub(); + } + + preValidationInit(req: Express.Request): Bluebird { + return this.preValidationInitStub(req); + } + + postValidationInit(req: Express.Request): Bluebird { + return this.postValidationInitStub(req); + } + + preValidationResponse(req: Express.Request, res: Express.Response): void { + return this.preValidationResponseStub(req, res); + } + + postValidationResponse(req: Express.Request, res: Express.Response): void { + return this.postValidationResponseStub(req, res); + } + + mailSubject(): string { + return this.mailSubjectStub(); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/Server.spec.ts b/themes/black/server/src/lib/Server.spec.ts new file mode 100644 index 00000000..36516325 --- /dev/null +++ b/themes/black/server/src/lib/Server.spec.ts @@ -0,0 +1,81 @@ + +import Assert = require("assert"); +import Sinon = require("sinon"); +import nedb = require("nedb"); +import express = require("express"); +import winston = require("winston"); +import speakeasy = require("speakeasy"); +import u2f = require("u2f"); +import session = require("express-session"); +import { Configuration } from "./configuration/schema/Configuration"; +import { GlobalDependencies } from "../../types/Dependencies"; +import Server from "./Server"; +import { LdapjsMock, LdapjsClientMock } from "./stubs/ldapjs.spec"; + + +describe("Server", function () { + let deps: GlobalDependencies; + let sessionMock: Sinon.SinonSpy; + let ldapjsMock: LdapjsMock; + + before(function () { + sessionMock = Sinon.spy(session); + ldapjsMock = new LdapjsMock(); + + deps = { + speakeasy: speakeasy, + u2f: u2f, + nedb: nedb, + winston: winston, + ldapjs: ldapjsMock as any, + session: sessionMock as any, + ConnectRedis: Sinon.spy(), + Redis: Sinon.spy() as any + }; + }); + + + it("should set cookie scope to domain set in the config", function () { + const config: Configuration = { + port: 8081, + session: { + domain: "example.com", + secret: "secret" + }, + authentication_backend: { + ldap: { + url: "http://ldap", + user: "user", + password: "password", + base_dn: "dc=example,dc=com" + }, + }, + notifier: { + email: { + username: "user@example.com", + password: "password", + sender: "test@authelia.com", + service: "gmail" + } + }, + regulation: { + max_retries: 3, + ban_time: 5 * 60, + find_time: 5 * 60 + }, + storage: { + local: { + in_memory: true + } + } + }; + + const server = new Server(deps); + server.start(config, deps) + .then(function () { + Assert(sessionMock.calledOnce); + Assert.equal(sessionMock.getCall(0).args[0].cookie.domain, "example.com"); + server.stop(); + }); + }); +}); diff --git a/themes/black/server/src/lib/Server.ts b/themes/black/server/src/lib/Server.ts new file mode 100644 index 00000000..4090f629 --- /dev/null +++ b/themes/black/server/src/lib/Server.ts @@ -0,0 +1,93 @@ +import BluebirdPromise = require("bluebird"); +import ObjectPath = require("object-path"); + +import { Configuration } from "./configuration/schema/Configuration"; +import { GlobalDependencies } from "../../types/Dependencies"; +import { UserDataStore } from "./storage/UserDataStore"; +import { ConfigurationParser } from "./configuration/ConfigurationParser"; +import { SessionConfigurationBuilder } from "./configuration/SessionConfigurationBuilder"; +import { GlobalLogger } from "./logging/GlobalLogger"; +import { RequestLogger } from "./logging/RequestLogger"; +import { ServerVariables } from "./ServerVariables"; +import { ServerVariablesInitializer } from "./ServerVariablesInitializer"; +import { Configurator } from "./web_server/Configurator"; + +import * as Express from "express"; +import * as Path from "path"; +import * as http from "http"; + +function clone(obj: any) { + return JSON.parse(JSON.stringify(obj)); +} + +export default class Server { + private httpServer: http.Server; + private globalLogger: GlobalLogger; + private requestLogger: RequestLogger; + + constructor(deps: GlobalDependencies) { + this.globalLogger = new GlobalLogger(deps.winston); + this.requestLogger = new RequestLogger(deps.winston); + } + + private displayConfigurations(configuration: Configuration) { + const displayableConfiguration: Configuration = clone(configuration); + const 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; + if (displayableConfiguration.notifier && displayableConfiguration.notifier.smtp) + displayableConfiguration.notifier.smtp.password = STARS; + + this.globalLogger.debug("User configuration is %s", + JSON.stringify(displayableConfiguration, undefined, 2)); + } + + private setup(config: Configuration, app: Express.Application, deps: GlobalDependencies): BluebirdPromise { + const that = this; + return ServerVariablesInitializer.initialize( + config, this.globalLogger, this.requestLogger, deps) + .then(function (vars: ServerVariables) { + Configurator.configure(config, app, vars, deps); + return BluebirdPromise.resolve(); + }); + } + + private startServer(app: Express.Application, port: number) { + const that = this; + that.globalLogger.info("Starting Authelia..."); + return new BluebirdPromise((resolve, reject) => { + this.httpServer = app.listen(port, function (err: string) { + that.globalLogger.info("Listening on port %d...", port); + resolve(); + }); + }); + } + + start(configuration: Configuration, deps: GlobalDependencies) + : BluebirdPromise { + const that = this; + const app = Express(); + + const appConfiguration = ConfigurationParser.parse(configuration); + + // by default the level of logs is info + deps.winston.level = appConfiguration.logs_level; + this.displayConfigurations(appConfiguration); + + return this.setup(appConfiguration, app, deps) + .then(function () { + return that.startServer(app, appConfiguration.port); + }); + } + + stop() { + this.httpServer.close(); + } +} + diff --git a/themes/black/server/src/lib/ServerVariables.ts b/themes/black/server/src/lib/ServerVariables.ts new file mode 100644 index 00000000..cd3dd6dc --- /dev/null +++ b/themes/black/server/src/lib/ServerVariables.ts @@ -0,0 +1,21 @@ +import { IRequestLogger } from "./logging/IRequestLogger"; +import { ITotpHandler } from "./authentication/totp/ITotpHandler"; +import { IU2fHandler } from "./authentication/u2f/IU2fHandler"; +import { IUserDataStore } from "./storage/IUserDataStore"; +import { INotifier } from "./notifiers/INotifier"; +import { IRegulator } from "./regulation/IRegulator"; +import { Configuration } from "./configuration/schema/Configuration"; +import { IAuthorizer } from "./authorization/IAuthorizer"; +import { IUsersDatabase } from "./authentication/backends/IUsersDatabase"; + +export interface ServerVariables { + logger: IRequestLogger; + usersDatabase: IUsersDatabase; + totpHandler: ITotpHandler; + u2f: IU2fHandler; + userDataStore: IUserDataStore; + notifier: INotifier; + regulator: IRegulator; + config: Configuration; + authorizer: IAuthorizer; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/ServerVariablesInitializer.ts b/themes/black/server/src/lib/ServerVariablesInitializer.ts new file mode 100644 index 00000000..df79238c --- /dev/null +++ b/themes/black/server/src/lib/ServerVariablesInitializer.ts @@ -0,0 +1,116 @@ + +import winston = require("winston"); +import BluebirdPromise = require("bluebird"); +import U2F = require("u2f"); +import Nodemailer = require("nodemailer"); + +import { IRequestLogger } from "./logging/IRequestLogger"; +import { RequestLogger } from "./logging/RequestLogger"; + +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 "./authentication/backends/ldap/LdapUsersDatabase"; +import { ConnectorFactory } from "./authentication/backends/ldap/connector/ConnectorFactory"; + +import { IUserDataStore } from "./storage/IUserDataStore"; +import { UserDataStore } from "./storage/UserDataStore"; +import { INotifier } from "./notifiers/INotifier"; +import { Regulator } from "./regulation/Regulator"; +import { IRegulator } from "./regulation/IRegulator"; +import Configuration = require("./configuration/schema/Configuration"); +import { CollectionFactoryFactory } from "./storage/CollectionFactoryFactory"; +import { ICollectionFactory } from "./storage/ICollectionFactory"; +import { MongoCollectionFactory } from "./storage/mongo/MongoCollectionFactory"; +import { IMongoClient } from "./connectors/mongo/IMongoClient"; + +import { GlobalDependencies } from "../../types/Dependencies"; +import { ServerVariables } from "./ServerVariables"; +import { MongoClient } from "./connectors/mongo/MongoClient"; +import { IGlobalLogger } from "./logging/IGlobalLogger"; +import { SessionFactory } from "./authentication/backends/ldap/SessionFactory"; +import { IUsersDatabase } from "./authentication/backends/IUsersDatabase"; +import { FileUsersDatabase } from "./authentication/backends/file/FileUsersDatabase"; +import { Authorizer } from "./authorization/Authorizer"; + +class UserDataStoreFactory { + static create(config: Configuration.Configuration, globalLogger: IGlobalLogger): BluebirdPromise { + if (config.storage.local) { + const nedbOptions: Nedb.DataStoreOptions = { + filename: config.storage.local.path, + inMemoryOnly: config.storage.local.in_memory + }; + const collectionFactory = CollectionFactoryFactory.createNedb(nedbOptions); + return BluebirdPromise.resolve(new UserDataStore(collectionFactory)); + } + else if (config.storage.mongo) { + const mongoClient = new MongoClient( + config.storage.mongo, + globalLogger); + const collectionFactory = CollectionFactoryFactory.createMongo(mongoClient); + return BluebirdPromise.resolve(new UserDataStore(collectionFactory)); + } + + return BluebirdPromise.reject(new Error("Storage backend incorrectly configured.")); + } +} + +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 { + + const mailSenderBuilder = + new MailSenderBuilder(Nodemailer); + const notifier = NotifierFactory.build( + config.notifier, mailSenderBuilder); + const authorizer = new Authorizer(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) { + const regulator = new Regulator(userDataStore, config.regulation.max_retries, + config.regulation.find_time, config.regulation.ban_time); + + const variables: ServerVariables = { + authorizer: authorizer, + config: config, + usersDatabase: usersDatabase, + logger: requestLogger, + notifier: notifier, + regulator: regulator, + totpHandler: totpHandler, + u2f: deps.u2f, + userDataStore: userDataStore + }; + return BluebirdPromise.resolve(variables); + }); + } +} diff --git a/themes/black/server/src/lib/ServerVariablesMockBuilder.spec.ts b/themes/black/server/src/lib/ServerVariablesMockBuilder.spec.ts new file mode 100644 index 00000000..7874702a --- /dev/null +++ b/themes/black/server/src/lib/ServerVariablesMockBuilder.spec.ts @@ -0,0 +1,87 @@ +import { ServerVariables } from "./ServerVariables"; + +import { Configuration } from "./configuration/schema/Configuration"; +import { IUsersDatabaseStub } from "./authentication/backends/IUsersDatabaseStub.spec"; +import { AuthorizerStub } from "./authorization/AuthorizerStub.spec"; +import { RequestLoggerStub } from "./logging/RequestLoggerStub.spec"; +import { NotifierStub } from "./notifiers/NotifierStub.spec"; +import { RegulatorStub } from "./regulation/RegulatorStub.spec"; +import { TotpHandlerStub } from "./authentication/totp/TotpHandlerStub.spec"; +import { UserDataStoreStub } from "./storage/UserDataStoreStub.spec"; +import { U2fHandlerStub } from "./authentication/u2f/U2fHandlerStub.spec"; + +export interface ServerVariablesMock { + authorizer: AuthorizerStub; + config: Configuration; + usersDatabase: IUsersDatabaseStub; + logger: RequestLoggerStub; + notifier: NotifierStub; + regulator: RegulatorStub; + totpHandler: TotpHandlerStub; + userDataStore: UserDataStoreStub; + u2f: U2fHandlerStub; +} + +export class ServerVariablesMockBuilder { + static build(enableLogging?: boolean): { variables: ServerVariables, mocks: ServerVariablesMock} { + const mocks: ServerVariablesMock = { + authorizer: new AuthorizerStub(), + config: { + access_control: {}, + totp: { + issuer: "authelia.com" + }, + 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: {}, + port: 8080, + regulation: { + ban_time: 50, + find_time: 50, + max_retries: 3 + }, + session: { + secret: "my_secret", + domain: "mydomain" + }, + storage: {} + }, + usersDatabase: new IUsersDatabaseStub(), + logger: new RequestLoggerStub(enableLogging), + notifier: new NotifierStub(), + regulator: new RegulatorStub(), + totpHandler: new TotpHandlerStub(), + userDataStore: new UserDataStoreStub(), + u2f: new U2fHandlerStub() + }; + const vars: ServerVariables = { + authorizer: mocks.authorizer, + config: mocks.config, + usersDatabase: mocks.usersDatabase, + logger: mocks.logger, + notifier: mocks.notifier, + regulator: mocks.regulator, + totpHandler: mocks.totpHandler, + userDataStore: mocks.userDataStore, + u2f: mocks.u2f + }; + + return { + variables: vars, + mocks: mocks + }; + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/Level.ts b/themes/black/server/src/lib/authentication/Level.ts new file mode 100644 index 00000000..57b6a234 --- /dev/null +++ b/themes/black/server/src/lib/authentication/Level.ts @@ -0,0 +1,5 @@ +export enum Level { + NOT_AUTHENTICATED = 0, + ONE_FACTOR = 1, + TWO_FACTOR = 2 +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/GroupsAndEmails.ts b/themes/black/server/src/lib/authentication/backends/GroupsAndEmails.ts new file mode 100644 index 00000000..3434ba66 --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/GroupsAndEmails.ts @@ -0,0 +1,5 @@ + +export interface GroupsAndEmails { + groups: string[]; + emails: string[]; +} diff --git a/themes/black/server/src/lib/authentication/backends/IUsersDatabase.ts b/themes/black/server/src/lib/authentication/backends/IUsersDatabase.ts new file mode 100644 index 00000000..d7fa13b7 --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/IUsersDatabase.ts @@ -0,0 +1,10 @@ +import Bluebird = require("bluebird"); + +import { GroupsAndEmails } from "./GroupsAndEmails"; + +export interface IUsersDatabase { + checkUserPassword(username: string, password: string): Bluebird; + getEmails(username: string): Bluebird; + getGroups(username: string): Bluebird; + updatePassword(username: string, newPassword: string): Bluebird; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/IUsersDatabaseStub.spec.ts b/themes/black/server/src/lib/authentication/backends/IUsersDatabaseStub.spec.ts new file mode 100644 index 00000000..19341a5d --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/IUsersDatabaseStub.spec.ts @@ -0,0 +1,35 @@ +import Bluebird = require("bluebird"); +import Sinon = require("sinon"); + +import { IUsersDatabase } from "./IUsersDatabase"; +import { GroupsAndEmails } from "./GroupsAndEmails"; + +export class IUsersDatabaseStub implements IUsersDatabase { + checkUserPasswordStub: Sinon.SinonStub; + getEmailsStub: Sinon.SinonStub; + getGroupsStub: Sinon.SinonStub; + updatePasswordStub: Sinon.SinonStub; + + constructor() { + this.checkUserPasswordStub = Sinon.stub(); + this.getEmailsStub = Sinon.stub(); + this.getGroupsStub = Sinon.stub(); + this.updatePasswordStub = Sinon.stub(); + } + + checkUserPassword(username: string, password: string): Bluebird { + return this.checkUserPasswordStub(username, password); + } + + getEmails(username: string): Bluebird { + return this.getEmailsStub(username); + } + + getGroups(username: string): Bluebird { + return this.getGroupsStub(username); + } + + updatePassword(username: string, newPassword: string): Bluebird { + return this.updatePasswordStub(username, newPassword); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/file/FileUsersDatabase.spec.ts b/themes/black/server/src/lib/authentication/backends/file/FileUsersDatabase.spec.ts new file mode 100644 index 00000000..a258a78f --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/file/FileUsersDatabase.spec.ts @@ -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()); + }); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/file/FileUsersDatabase.ts b/themes/black/server/src/lib/authentication/backends/file/FileUsersDatabase.ts new file mode 100644 index 00000000..d34dde21 --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/file/FileUsersDatabase.ts @@ -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 { + return new Bluebird((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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.readDatabase() + .then((database) => { + return this.checkUserExists(database, username) + .then(() => this.retrieveEmails(database, username)); + }); + } + + getGroups(username: string): Bluebird { + return this.readDatabase() + .then((database) => { + return this.checkUserExists(database, username) + .then(() => this.retrieveGroups(database, username)); + }); + } + + updatePassword(username: string, newPassword: string): Bluebird { + return this.readDatabase() + .then((database) => { + return this.checkUserExists(database, username) + .then(() => this.replacePassword(database, username, newPassword)); + }); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/file/ReadWriteQueue.ts b/themes/black/server/src/lib/authentication/backends/file/ReadWriteQueue.ts new file mode 100644 index 00000000..957ddaec --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/file/ReadWriteQueue.ts @@ -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(); + } + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/ldap/ISession.ts b/themes/black/server/src/lib/authentication/backends/ldap/ISession.ts new file mode 100644 index 00000000..da2c7443 --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/ISession.ts @@ -0,0 +1,12 @@ + +import BluebirdPromise = require("bluebird"); + +export interface ISession { + open(): BluebirdPromise; + close(): BluebirdPromise; + + searchUserDn(username: string): BluebirdPromise; + searchEmails(username: string): BluebirdPromise; + searchGroups(username: string): BluebirdPromise; + modifyPassword(username: string, newPassword: string): BluebirdPromise; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/ldap/ISessionFactory.ts b/themes/black/server/src/lib/authentication/backends/ldap/ISessionFactory.ts new file mode 100644 index 00000000..014d1eea --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/ISessionFactory.ts @@ -0,0 +1,6 @@ + +import { ISession } from "./ISession"; + +export interface ISessionFactory { + create(userDN: string, password: string): ISession; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.spec.ts b/themes/black/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.spec.ts new file mode 100644 index 00000000..f4a6e630 --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.spec.ts @@ -0,0 +1,386 @@ +import Assert = require("assert"); +import Bluebird = require("bluebird"); + +import { LdapUsersDatabase } from "./LdapUsersDatabase"; + +import { SessionFactoryStub } from "./SessionFactoryStub.spec"; +import { SessionStub } from "./SessionStub.spec"; + +const ADMIN_USER_DN = "cn=admin,dc=example,dc=com"; +const ADMIN_PASSWORD = "password"; + +describe("ldap/connector/LdapUsersDatabase", function() { + let sessionFactory: SessionFactoryStub; + let usersDatabase: LdapUsersDatabase; + + const USERNAME = "user"; + const PASSWORD = "pass"; + const NEW_PASSWORD = "pass2"; + + const LDAP_CONFIG = { + url: "http://localhost:324", + additional_users_dn: "ou=users", + additional_groups_dn: "ou=groups", + base_dn: "dc=example,dc=com", + users_filter: "cn={0}", + groups_filter: "member={0}", + mail_attribute: "mail", + group_name_attribute: "cn", + user: ADMIN_USER_DN, + password: ADMIN_PASSWORD + }; + + beforeEach(function() { + sessionFactory = new SessionFactoryStub(); + usersDatabase = new LdapUsersDatabase(sessionFactory, LDAP_CONFIG); + }) + + describe("checkUserPassword", function() { + it("should return groups and emails when user/password matches", function() { + const USER_DN = `cn=${USERNAME},dc=example,dc=com`; + const emails = ["email1", "email2"]; + const groups = ["group1", "group2"]; + + const adminSession = new SessionStub(); + const userSession = new SessionStub(); + + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(adminSession); + sessionFactory.createStub.withArgs(USER_DN, PASSWORD).returns(userSession); + + adminSession.openStub.returns(Bluebird.resolve()); + adminSession.closeStub.returns(Bluebird.resolve()); + adminSession.searchUserDnStub.returns(Bluebird.resolve(USER_DN)); + adminSession.searchEmailsStub.withArgs(USERNAME).returns(Bluebird.resolve(emails)); + adminSession.searchGroupsStub.withArgs(USERNAME).returns(Bluebird.resolve(groups)); + + userSession.openStub.returns(Bluebird.resolve()); + userSession.closeStub.returns(Bluebird.resolve()); + + return usersDatabase.checkUserPassword(USERNAME, PASSWORD) + .then((groupsAndEmails) => { + Assert.deepEqual(groupsAndEmails.groups, groups); + Assert.deepEqual(groupsAndEmails.emails, emails); + }) + }); + + it("should fail when username/password is wrong", function() { + const USER_DN = `cn=${USERNAME},dc=example,dc=com`; + + const adminSession = new SessionStub(); + const userSession = new SessionStub(); + + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(adminSession); + sessionFactory.createStub.withArgs(USER_DN, PASSWORD).returns(userSession); + + adminSession.openStub.returns(Bluebird.resolve()); + adminSession.closeStub.returns(Bluebird.resolve()); + adminSession.searchUserDnStub.returns(Bluebird.resolve(USER_DN)); + + userSession.openStub.returns(Bluebird.reject(new Error("Failed binding"))); + userSession.closeStub.returns(Bluebird.resolve()); + + return usersDatabase.checkUserPassword(USERNAME, PASSWORD) + .then(() => Bluebird.reject(new Error("should not be here"))) + .catch((err) => { + Assert(userSession.closeStub.called); + Assert(adminSession.closeStub.called); + return Bluebird.resolve(); + }) + }); + + it("should fail when admin binding fails", function() { + const USER_DN = `cn=${USERNAME},dc=example,dc=com`; + + const adminSession = new SessionStub(); + const userSession = new SessionStub(); + + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(adminSession); + sessionFactory.createStub.withArgs(USER_DN, PASSWORD).returns(userSession); + + adminSession.openStub.returns(Bluebird.reject(new Error("Failed binding"))); + adminSession.closeStub.returns(Bluebird.resolve()); + adminSession.searchUserDnStub.returns(Bluebird.resolve(USER_DN)); + + return usersDatabase.checkUserPassword(USERNAME, PASSWORD) + .then(() => Bluebird.reject(new Error("should not be here"))) + .catch((err) => { + Assert(userSession.closeStub.notCalled); + Assert(adminSession.closeStub.called); + return Bluebird.resolve(); + }) + }); + + it("should fail when search for user dn fails", function() { + const USER_DN = `cn=${USERNAME},dc=example,dc=com`; + + const adminSession = new SessionStub(); + const userSession = new SessionStub(); + + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(adminSession); + sessionFactory.createStub.withArgs(USER_DN, PASSWORD).returns(userSession); + + adminSession.openStub.returns(Bluebird.resolve()); + adminSession.closeStub.returns(Bluebird.resolve()); + adminSession.searchUserDnStub.returns(Bluebird.reject(new Error("Failed searching user dn"))); + + return usersDatabase.checkUserPassword(USERNAME, PASSWORD) + .then(() => Bluebird.reject(new Error("should not be here"))) + .catch((err) => { + Assert(userSession.closeStub.notCalled); + Assert(adminSession.closeStub.called); + return Bluebird.resolve(); + }) + }); + + it("should fail when groups retrieval fails", function() { + const USER_DN = `cn=${USERNAME},dc=example,dc=com`; + const emails = ["email1", "email2"]; + const groups = ["group1", "group2"]; + + const adminSession = new SessionStub(); + const userSession = new SessionStub(); + + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(adminSession); + sessionFactory.createStub.withArgs(USER_DN, PASSWORD).returns(userSession); + + adminSession.openStub.returns(Bluebird.resolve()); + adminSession.closeStub.returns(Bluebird.resolve()); + adminSession.searchUserDnStub.returns(Bluebird.resolve(USER_DN)); + adminSession.searchEmailsStub.withArgs(USERNAME) + .returns(Bluebird.resolve(emails)); + adminSession.searchGroupsStub.withArgs(USERNAME) + .returns(Bluebird.reject(new Error("Failed retrieving groups"))); + + userSession.openStub.returns(Bluebird.resolve()); + userSession.closeStub.returns(Bluebird.resolve()); + + return usersDatabase.checkUserPassword(USERNAME, PASSWORD) + .then((groupsAndEmails) => Bluebird.reject(new Error("should not be here"))) + .catch((err) => { + Assert(userSession.closeStub.called); + Assert(adminSession.closeStub.called); + }) + }); + + it("should fail when emails retrieval fails", function() { + const USER_DN = `cn=${USERNAME},dc=example,dc=com`; + const emails = ["email1", "email2"]; + const groups = ["group1", "group2"]; + + const adminSession = new SessionStub(); + const userSession = new SessionStub(); + + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(adminSession); + sessionFactory.createStub.withArgs(USER_DN, PASSWORD).returns(userSession); + + adminSession.openStub.returns(Bluebird.resolve()); + adminSession.closeStub.returns(Bluebird.resolve()); + adminSession.searchUserDnStub.returns(Bluebird.resolve(USER_DN)); + adminSession.searchEmailsStub.withArgs(USERNAME) + .returns(Bluebird.reject(new Error("Emails retrieval failed"))); + adminSession.searchGroupsStub.withArgs(USERNAME) + .returns(Bluebird.resolve(groups)); + + userSession.openStub.returns(Bluebird.resolve()); + userSession.closeStub.returns(Bluebird.resolve()); + + return usersDatabase.checkUserPassword(USERNAME, PASSWORD) + .then((groupsAndEmails) => Bluebird.reject(new Error("should not be here"))) + .catch((err) => { + Assert(userSession.closeStub.called); + Assert(adminSession.closeStub.called); + }) + }); + }); + + describe("getEmails", function() { + it("should succefully retrieves email", () => { + const emails = ["email1", "email2"]; + const session = new SessionStub(); + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(session); + + session.openStub.returns(Bluebird.resolve()); + session.closeStub.returns(Bluebird.resolve()); + session.searchEmailsStub.returns(Bluebird.resolve(emails)); + + return usersDatabase.getEmails(USERNAME) + .then((foundEmails) => { + Assert(session.closeStub.called); + Assert.deepEqual(foundEmails, emails); + }) + }); + + it("should fail when binding fails", () => { + const emails = ["email1", "email2"]; + const session = new SessionStub(); + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(session); + + session.openStub.returns(Bluebird.reject(new Error("Binding failed"))); + + return usersDatabase.getEmails(USERNAME) + .then(() => Bluebird.reject(new Error("should not be here"))) + .catch((err) => { + Assert(session.closeStub.called); + }) + }); + + it("should fail when unbinding fails", () => { + const emails = ["email1", "email2"]; + const session = new SessionStub(); + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(session); + + session.openStub.returns(Bluebird.resolve()); + session.searchEmailsStub.returns(Bluebird.resolve(emails)); + session.closeStub.returns(Bluebird.reject(new Error("Unbinding failed"))); + + return usersDatabase.getEmails(USERNAME) + .then(() => Bluebird.reject(new Error("should not be here"))) + .catch((err) => { + Assert(session.closeStub.called); + }) + }); + + it("should fail when search fails", () => { + const emails = ["email1", "email2"]; + const session = new SessionStub(); + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(session); + + session.openStub.returns(Bluebird.resolve()); + session.searchEmailsStub.returns(Bluebird.reject(new Error("Search failed"))); + session.closeStub.returns(Bluebird.resolve()); + + return usersDatabase.getEmails(USERNAME) + .then(() => Bluebird.reject(new Error("should not be here"))) + .catch((err) => { + Assert(session.closeStub.called); + }) + }); + }); + + + describe("getGroups", function() { + it("should succefully retrieves groups", () => { + const groups = ["group1", "group2"]; + const session = new SessionStub(); + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(session); + + session.openStub.returns(Bluebird.resolve()); + session.closeStub.returns(Bluebird.resolve()); + session.searchGroupsStub.returns(Bluebird.resolve(groups)); + + return usersDatabase.getGroups(USERNAME) + .then((foundGroups) => { + Assert(session.closeStub.called); + Assert.deepEqual(foundGroups, groups); + }) + }); + + it("should fail when binding fails", () => { + const session = new SessionStub(); + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(session); + + session.openStub.returns(Bluebird.reject(new Error("Binding failed"))); + + return usersDatabase.getGroups(USERNAME) + .then(() => Bluebird.reject(new Error("should not be here"))) + .catch((err) => { + Assert(session.closeStub.called); + }) + }); + + it("should fail when unbinding fails", () => { + const groups = ["group1", "group2"]; + const session = new SessionStub(); + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(session); + + session.openStub.returns(Bluebird.resolve()); + session.searchGroupsStub.returns(Bluebird.resolve(groups)); + session.closeStub.returns(Bluebird.reject(new Error("Unbinding failed"))); + + return usersDatabase.getGroups(USERNAME) + .then(() => Bluebird.reject(new Error("should not be here"))) + .catch((err) => { + Assert(session.closeStub.called); + }) + }); + + it("should fail when search fails", () => { + const groups = ["group1", "group2"]; + const session = new SessionStub(); + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(session); + + session.openStub.returns(Bluebird.resolve()); + session.searchGroupsStub.returns(Bluebird.reject(new Error("Search failed"))); + session.closeStub.returns(Bluebird.resolve()); + + return usersDatabase.getGroups(USERNAME) + .then(() => Bluebird.reject(new Error("should not be here"))) + .catch((err) => { + Assert(session.closeStub.called); + }) + }); + }); + + + describe("updatePassword", function() { + it("should successfully update password", () => { + const session = new SessionStub(); + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(session); + + session.openStub.returns(Bluebird.resolve()); + session.closeStub.returns(Bluebird.resolve()); + session.modifyPasswordStub.returns(Bluebird.resolve()); + + return usersDatabase.updatePassword(USERNAME, NEW_PASSWORD) + .then(() => { + Assert(session.modifyPasswordStub.calledWith(USERNAME, NEW_PASSWORD)); + Assert(session.closeStub.called); + }) + }); + + it("should fail when binding fails", () => { + const session = new SessionStub(); + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(session); + + session.openStub.returns(Bluebird.reject(new Error("Binding failed"))); + session.closeStub.returns(Bluebird.resolve()); + session.modifyPasswordStub.returns(Bluebird.resolve()); + + return usersDatabase.updatePassword(USERNAME, NEW_PASSWORD) + .then(() => Bluebird.reject(new Error("should not be here"))) + .catch(() => { + Assert(session.closeStub.called); + }) + }); + + it("should fail when update fails", () => { + const session = new SessionStub(); + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(session); + + session.openStub.returns(Bluebird.resolve()); + session.closeStub.returns(Bluebird.reject(new Error("Update failed"))); + session.modifyPasswordStub.returns(Bluebird.resolve()); + + return usersDatabase.updatePassword(USERNAME, NEW_PASSWORD) + .then(() => Bluebird.reject(new Error("should not be here"))) + .catch(() => { + Assert(session.closeStub.called); + }) + }); + + it("should fail when unbind fails", () => { + const session = new SessionStub(); + sessionFactory.createStub.withArgs(ADMIN_USER_DN, ADMIN_PASSWORD).returns(session); + + session.openStub.returns(Bluebird.resolve()); + session.closeStub.returns(Bluebird.resolve()); + session.modifyPasswordStub.returns(Bluebird.reject(new Error("Unbind failed"))); + + return usersDatabase.updatePassword(USERNAME, NEW_PASSWORD) + .then(() => Bluebird.reject(new Error("should not be here"))) + .catch(() => { + Assert(session.closeStub.called); + }) + }); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.ts b/themes/black/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.ts new file mode 100644 index 00000000..edda62ec --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.ts @@ -0,0 +1,107 @@ +import Bluebird = require("bluebird"); +import { IUsersDatabase } from "../IUsersDatabase"; +import { ISessionFactory } from "./ISessionFactory"; +import { LdapConfiguration } from "../../../configuration/schema/LdapConfiguration"; +import { ISession } from "./ISession"; +import { GroupsAndEmails } from "../GroupsAndEmails"; +import Exceptions = require("../../../Exceptions"); + +type SessionCallback = (session: ISession) => Bluebird; + +export class LdapUsersDatabase implements IUsersDatabase { + private sessionFactory: ISessionFactory; + private configuration: LdapConfiguration; + + constructor( + sessionFactory: ISessionFactory, + configuration: LdapConfiguration) { + this.sessionFactory = sessionFactory; + this.configuration = configuration; + } + + private withSession( + username: string, + password: string, + cb: SessionCallback): Bluebird { + const session = this.sessionFactory.create(username, password); + return session.open() + .then(() => cb(session)) + .finally(() => session.close()); + } + + checkUserPassword(username: string, password: string): Bluebird { + const that = this; + function verifyUserPassword(userDN: string) { + return that.withSession( + userDN, + password, + (session) => Bluebird.resolve() + ); + } + + function getInfo(session: ISession) { + return Bluebird.join( + session.searchGroups(username), + session.searchEmails(username) + ) + .spread((groups: string[], emails: string[]) => { + return { groups: groups, emails: emails }; + }); + } + + return that.withSession( + that.configuration.user, + that.configuration.password, + (session) => { + return session.searchUserDn(username) + .then(verifyUserPassword) + .then(() => getInfo(session)); + }) + .catch((err) => + Bluebird.reject(new Exceptions.LdapError(err.message))); + } + + getEmails(username: string): Bluebird { + const that = this; + return that.withSession( + that.configuration.user, + that.configuration.password, + (session) => { + return session.searchEmails(username); + } + ) + .catch((err) => + Bluebird.reject(new Exceptions.LdapError("Failed during email retrieval: " + err.message)) + ); + } + + getGroups(username: string): Bluebird { + const that = this; + return that.withSession( + that.configuration.user, + that.configuration.password, + (session) => { + return session.searchGroups(username); + } + ) + .catch((err) => + Bluebird.reject(new Exceptions.LdapError("Failed during email retrieval: " + err.message)) + ); + } + + updatePassword(username: string, newPassword: string): Bluebird { + const that = this; + return that.withSession( + that.configuration.user, + that.configuration.password, + (session) => { + return session.modifyPassword(username, newPassword); + } + ) + .catch(function (err: Error) { + return Bluebird.reject( + new Exceptions.LdapError( + "Error while updating password: " + err.message)); + }); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/ldap/SafeSession.spec.ts b/themes/black/server/src/lib/authentication/backends/ldap/SafeSession.spec.ts new file mode 100644 index 00000000..9dedfcb7 --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/SafeSession.spec.ts @@ -0,0 +1,76 @@ +import BluebirdPromise = require("bluebird"); +import { SessionStub } from "./SessionStub.spec"; +import { SafeSession } from "./SafeSession"; + +describe("ldap/SanitizedClient", function () { + let client: SafeSession; + + beforeEach(function () { + const clientStub = new SessionStub(); + clientStub.searchUserDnStub.onCall(0).returns(BluebirdPromise.resolve()); + clientStub.searchGroupsStub.onCall(0).returns(BluebirdPromise.resolve()); + clientStub.searchEmailsStub.onCall(0).returns(BluebirdPromise.resolve()); + clientStub.modifyPasswordStub.onCall(0).returns(BluebirdPromise.resolve()); + client = new SafeSession(clientStub); + }); + + describe("special chars are used", function () { + it("should fail when special chars are used in searchUserDn", function () { + // potential ldap injection"; + return client.searchUserDn("cn=dummy_user,ou=groupgs") + .then(function () { + return BluebirdPromise.reject(new Error("Should not be here.")); + }, function () { + return BluebirdPromise.resolve(); + }); + }); + + it("should fail when special chars are used in searchGroups", function () { + // potential ldap injection"; + return client.searchGroups("cn=dummy_user,ou=groupgs") + .then(function () { + return BluebirdPromise.reject(new Error("Should not be here.")); + }, function () { + return BluebirdPromise.resolve(); + }); + }); + + it("should fail when special chars are used in searchEmails", function () { + // potential ldap injection"; + return client.searchEmails("cn=dummy_user,ou=groupgs") + .then(function () { + return BluebirdPromise.reject(new Error("Should not be here.")); + }, function () { + return BluebirdPromise.resolve(); + }); + }); + + it("should fail when special chars are used in modifyPassword", function () { + // potential ldap injection"; + return client.modifyPassword("cn=dummy_user,ou=groupgs", "abc") + .then(function () { + return BluebirdPromise.reject(new Error("Should not be here.")); + }, function () { + return BluebirdPromise.resolve(); + }); + }); + }); + + describe("no special chars are used", function() { + it("should succeed when no special chars are used in searchUserDn", function () { + return client.searchUserDn("dummy_user"); + }); + + it("should succeed when no special chars are used in searchGroups", function () { + return client.searchGroups("dummy_user"); + }); + + it("should succeed when no special chars are used in searchEmails", function () { + return client.searchEmails("dummy_user"); + }); + + it("should succeed when no special chars are used in modifyPassword", function () { + return client.modifyPassword("dummy_user", "abc"); + }); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/ldap/SafeSession.ts b/themes/black/server/src/lib/authentication/backends/ldap/SafeSession.ts new file mode 100644 index 00000000..57220906 --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/SafeSession.ts @@ -0,0 +1,62 @@ +import BluebirdPromise = require("bluebird"); +import { ISession } from "./ISession"; +import { Sanitizer } from "./Sanitizer"; + +const SPECIAL_CHAR_USED_MESSAGE = "Special character used in LDAP query."; + + +export class SafeSession implements ISession { + private sesion: ISession; + + constructor(sesion: ISession) { + this.sesion = sesion; + } + + open(): BluebirdPromise { + return this.sesion.open(); + } + + close(): BluebirdPromise { + return this.sesion.close(); + } + + searchGroups(username: string): BluebirdPromise { + try { + const sanitizedUsername = Sanitizer.sanitize(username); + return this.sesion.searchGroups(sanitizedUsername); + } + catch (e) { + return BluebirdPromise.reject(new Error(SPECIAL_CHAR_USED_MESSAGE)); + } + } + + searchUserDn(username: string): BluebirdPromise { + try { + const sanitizedUsername = Sanitizer.sanitize(username); + return this.sesion.searchUserDn(sanitizedUsername); + } + catch (e) { + return BluebirdPromise.reject(new Error(SPECIAL_CHAR_USED_MESSAGE)); + } + } + + searchEmails(username: string): BluebirdPromise { + try { + const sanitizedUsername = Sanitizer.sanitize(username); + return this.sesion.searchEmails(sanitizedUsername); + } + catch (e) { + return BluebirdPromise.reject(new Error(SPECIAL_CHAR_USED_MESSAGE)); + } + } + + modifyPassword(username: string, newPassword: string): BluebirdPromise { + try { + const sanitizedUsername = Sanitizer.sanitize(username); + return this.sesion.modifyPassword(sanitizedUsername, newPassword); + } + catch (e) { + return BluebirdPromise.reject(new Error(SPECIAL_CHAR_USED_MESSAGE)); + } + } +} diff --git a/themes/black/server/src/lib/authentication/backends/ldap/Sanitizer.spec.ts b/themes/black/server/src/lib/authentication/backends/ldap/Sanitizer.spec.ts new file mode 100644 index 00000000..9dd33fed --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/Sanitizer.spec.ts @@ -0,0 +1,25 @@ +import Assert = require("assert"); +import { Sanitizer } from "./Sanitizer"; + +describe("ldap/InputsSanitizer", function () { + it("should fail when special characters are used", function () { + Assert.throws(() => { Sanitizer.sanitize("ab,c"); }, Error); + Assert.throws(() => { Sanitizer.sanitize("a\\bc"); }, Error); + Assert.throws(() => { Sanitizer.sanitize("a'bc"); }, Error); + Assert.throws(() => { Sanitizer.sanitize("a#bc"); }, Error); + Assert.throws(() => { Sanitizer.sanitize("a+bc"); }, Error); + Assert.throws(() => { Sanitizer.sanitize("a { Sanitizer.sanitize("a>bc"); }, Error); + Assert.throws(() => { Sanitizer.sanitize("a;bc"); }, Error); + Assert.throws(() => { Sanitizer.sanitize("a\"bc"); }, Error); + Assert.throws(() => { Sanitizer.sanitize("a=bc"); }, Error); + }); + + it("should return original string", function () { + Assert.equal(Sanitizer.sanitize("abcdef"), "abcdef"); + }); + + it("should trim", function () { + Assert.throws(() => { Sanitizer.sanitize(" abc "); }, Error); + }); +}); diff --git a/themes/black/server/src/lib/authentication/backends/ldap/Sanitizer.ts b/themes/black/server/src/lib/authentication/backends/ldap/Sanitizer.ts new file mode 100644 index 00000000..be74132a --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/Sanitizer.ts @@ -0,0 +1,25 @@ + +// returns true for 1 or more matches, where 'a' is an array and 'b' is a search string or an array of multiple search strings +function contains(a: string, character: string) { + // string match + return a.indexOf(character) > -1; +} + +function containsOneOf(s: string, characters: string[]) { + return characters + .map((character: string) => { return contains(s, character); }) + .reduce((acc: boolean, current: boolean) => { return acc || current; }, false); +} + +export class Sanitizer { + static sanitize(input: string): string { + const forbiddenChars = [",", "\\", "'", "#", "+", "<", ">", ";", "\"", "="]; + if (containsOneOf(input, forbiddenChars)) + throw new Error("Input containing unsafe characters."); + + if (input != input.trim()) + throw new Error("Input has unexpected spaces."); + + return input; + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/ldap/Session.spec.ts b/themes/black/server/src/lib/authentication/backends/ldap/Session.spec.ts new file mode 100644 index 00000000..d55f6a80 --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/Session.spec.ts @@ -0,0 +1,127 @@ + +import { LdapConfiguration } from "../../../configuration/schema/LdapConfiguration"; +import { Session } from "./Session"; +import { ConnectorFactoryStub } from "./connector/ConnectorFactoryStub.spec"; +import { ConnectorStub } from "./connector/ConnectorStub.spec"; + +import Sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); +import Assert = require("assert"); +import Winston = require("winston"); + +describe("ldap/Session", function () { + const USERNAME = "username"; + const ADMIN_USER_DN = "cn=admin,dc=example,dc=com"; + const ADMIN_PASSWORD = "password"; + + it("should replace {0} by username when searching for groups in LDAP", function () { + const options: LdapConfiguration = { + url: "ldap://ldap", + additional_users_dn: "ou=users", + additional_groups_dn: "ou=groups", + base_dn: "dc=example,dc=com", + users_filter: "cn={0}", + groups_filter: "member=cn={0},ou=users,dc=example,dc=com", + group_name_attribute: "cn", + mail_attribute: "mail", + user: "cn=admin,dc=example,dc=com", + password: "password" + }; + const connectorStub = new ConnectorStub(); + connectorStub.searchAsyncStub.returns(BluebirdPromise.resolve([{ + cn: "group1" + }])); + const client = new Session(ADMIN_USER_DN, ADMIN_PASSWORD, options, connectorStub, Winston); + + return client.searchGroups("user1") + .then(function () { + Assert.equal(connectorStub.searchAsyncStub.getCall(0).args[1].filter, + "member=cn=user1,ou=users,dc=example,dc=com"); + }); + }); + + it("should replace {dn} by user DN when searching for groups in LDAP", function () { + const USER_DN = "cn=user1,ou=users,dc=example,dc=com"; + const options: LdapConfiguration = { + url: "ldap://ldap", + additional_users_dn: "ou=users", + additional_groups_dn: "ou=groups", + base_dn: "dc=example,dc=com", + users_filter: "cn={0}", + groups_filter: "member={dn}", + group_name_attribute: "cn", + mail_attribute: "mail", + user: "cn=admin,dc=example,dc=com", + password: "password" + }; + const ldapClient = new ConnectorStub(); + + // Retrieve user DN + ldapClient.searchAsyncStub.withArgs("ou=users,dc=example,dc=com", { + scope: "sub", + sizeLimit: 1, + attributes: ["dn"], + filter: "cn=user1" + }).returns(BluebirdPromise.resolve([{ + dn: USER_DN + }])); + + // Retrieve groups + ldapClient.searchAsyncStub.withArgs("ou=groups,dc=example,dc=com", { + scope: "sub", + attributes: ["cn"], + filter: "member=" + USER_DN + }).returns(BluebirdPromise.resolve([{ + cn: "group1" + }])); + + const client = new Session(ADMIN_USER_DN, ADMIN_PASSWORD, options, ldapClient, Winston); + + return client.searchGroups("user1") + .then(function (groups: string[]) { + Assert.deepEqual(groups, ["group1"]); + }); + }); + + it("should retrieve mail from custom attribute", function () { + const USER_DN = "cn=user1,ou=users,dc=example,dc=com"; + const options: LdapConfiguration = { + url: "ldap://ldap", + additional_users_dn: "ou=users", + additional_groups_dn: "ou=groups", + base_dn: "dc=example,dc=com", + users_filter: "cn={0}", + groups_filter: "member={dn}", + group_name_attribute: "cn", + mail_attribute: "custom_mail", + user: "cn=admin,dc=example,dc=com", + password: "password" + }; + const connector = new ConnectorStub(); + // Retrieve user DN + connector.searchAsyncStub.withArgs("ou=users,dc=example,dc=com", { + scope: "sub", + sizeLimit: 1, + attributes: ["dn"], + filter: "cn=user1" + }).returns(BluebirdPromise.resolve([{ + dn: USER_DN + }])); + + // Retrieve email + connector.searchAsyncStub.withArgs("cn=user1,ou=users,dc=example,dc=com", { + scope: "base", + sizeLimit: 1, + attributes: ["custom_mail"], + }).returns(BluebirdPromise.resolve([{ + custom_mail: "user1@example.com" + }])); + + const client = new Session(ADMIN_USER_DN, ADMIN_PASSWORD, options, connector, Winston); + + return client.searchEmails("user1") + .then(function (emails: string[]) { + Assert.deepEqual(emails, ["user1@example.com"]); + }); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/ldap/Session.ts b/themes/black/server/src/lib/authentication/backends/ldap/Session.ts new file mode 100644 index 00000000..e0284b3c --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/Session.ts @@ -0,0 +1,156 @@ +import BluebirdPromise = require("bluebird"); +import exceptions = require("../../../Exceptions"); +import { EventEmitter } from "events"; +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 { IConnector } from "./connector/IConnector"; + +export class Session implements ISession { + private userDN: string; + private password: string; + private connector: IConnector; + private logger: Winston; + private options: LdapConfiguration; + + private groupsSearchBase: string; + private usersSearchBase: string; + + constructor(userDN: string, password: string, options: LdapConfiguration, + connector: IConnector, logger: Winston) { + this.options = options; + this.logger = logger; + this.userDN = userDN; + this.password = password; + this.connector = connector; + + this.groupsSearchBase = (this.options.additional_groups_dn) + ? Util.format("%s,%s", this.options.additional_groups_dn, this.options.base_dn) + : this.options.base_dn; + + this.usersSearchBase = (this.options.additional_users_dn) + ? Util.format("%s,%s", this.options.additional_users_dn, this.options.base_dn) + : this.options.base_dn; + } + + open(): BluebirdPromise { + this.logger.debug("LDAP: Bind user '%s'", this.userDN); + return this.connector.bindAsync(this.userDN, this.password) + .error(function (err: Error) { + return BluebirdPromise.reject(new exceptions.LdapBindError(err.message)); + }); + } + + close(): BluebirdPromise { + this.logger.debug("LDAP: Unbind user '%s'", this.userDN); + return this.connector.unbindAsync() + .error(function (err: Error) { + return BluebirdPromise.reject(new exceptions.LdapBindError(err.message)); + }); + } + + private createGroupsFilter(userGroupsFilter: string, username: string): BluebirdPromise { + if (userGroupsFilter.indexOf("{0}") > 0) { + return BluebirdPromise.resolve(userGroupsFilter.replace("{0}", username)); + } + else if (userGroupsFilter.indexOf("{dn}") > 0) { + return this.searchUserDn(username) + .then(function (userDN: string) { + return BluebirdPromise.resolve(userGroupsFilter.replace("{dn}", userDN)); + }); + } + return BluebirdPromise.resolve(userGroupsFilter); + } + + searchGroups(username: string): BluebirdPromise { + const that = this; + return this.createGroupsFilter(this.options.groups_filter, username) + .then(function (groupsFilter: string) { + that.logger.debug("Computed groups filter is %s", groupsFilter); + const query = { + scope: "sub", + attributes: [that.options.group_name_attribute], + filter: groupsFilter + }; + return that.connector.searchAsync(that.groupsSearchBase, query); + }) + .then(function (docs: { cn: string }[]) { + const groups = docs.map((doc: any) => { return doc.cn; }); + that.logger.debug("LDAP: groups of user %s are [%s]", username, groups.join(",")); + return BluebirdPromise.resolve(groups); + }); + } + + searchUserDn(username: string): BluebirdPromise { + const that = this; + const filter = this.options.users_filter.replace("{0}", username); + this.logger.debug("Computed users filter is %s", filter); + const query = { + scope: "sub", + sizeLimit: 1, + attributes: ["dn"], + filter: filter + }; + + that.logger.debug("LDAP: searching for user dn of %s", username); + return that.connector.searchAsync(this.usersSearchBase, query) + .then(function (users: { dn: string }[]) { + if (users.length > 0) { + that.logger.debug("LDAP: retrieved user dn is %s", users[0].dn); + return BluebirdPromise.resolve(users[0].dn); + } + return BluebirdPromise.reject(new Error( + Util.format("No user DN found for user '%s'", username))); + }); + } + + searchEmails(username: string): BluebirdPromise { + const that = this; + const query = { + scope: "base", + sizeLimit: 1, + attributes: [this.options.mail_attribute] + }; + + return this.searchUserDn(username) + .then(function (userDN) { + return that.connector.searchAsync(userDN, query); + }) + .then(function (docs: { [mail_attribute: string]: string }[]) { + const emails: string[] = docs + .filter((d) => { return typeof d[that.options.mail_attribute] === "string"; }) + .map((d) => { return d[that.options.mail_attribute]; }); + that.logger.debug("LDAP: emails of user '%s' are %s", username, emails); + return BluebirdPromise.resolve(emails); + }) + .catch(function (err: Error) { + return BluebirdPromise.reject(new exceptions.LdapError("Error while searching emails. " + err.stack)); + }); + } + + modifyPassword(username: string, newPassword: string): BluebirdPromise { + const that = this; + this.logger.debug("LDAP: update password of user '%s'", username); + return this.searchUserDn(username) + .then(function (userDN: string) { + return BluebirdPromise.join( + HashGenerator.ssha512(newPassword), + BluebirdPromise.resolve(userDN)); + }) + .then(function (res: string[]) { + const change = { + operation: "replace", + modification: { + userPassword: res[0] + } + }; + that.logger.debug("Password new='%s'", change.modification.userPassword); + return that.connector.modifyAsync(res[1], change); + }) + .then(function () { + return that.connector.unbindAsync(); + }); + } +} diff --git a/themes/black/server/src/lib/authentication/backends/ldap/SessionFactory.ts b/themes/black/server/src/lib/authentication/backends/ldap/SessionFactory.ts new file mode 100644 index 00000000..0b6c4bff --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/SessionFactory.ts @@ -0,0 +1,37 @@ +import Ldapjs = require("ldapjs"); +import Winston = require("winston"); + +import { IConnectorFactory } from "./connector/IConnectorFactory"; +import { ISessionFactory } from "./ISessionFactory"; +import { ISession } from "./ISession"; +import { LdapConfiguration } from "../../../configuration/schema/LdapConfiguration"; +import { Session } from "./Session"; +import { SafeSession } from "./SafeSession"; + + +export class SessionFactory implements ISessionFactory { + private config: LdapConfiguration; + private connectorFactory: IConnectorFactory; + private logger: typeof Winston; + + constructor(ldapConfiguration: LdapConfiguration, + connectorFactory: IConnectorFactory, + logger: typeof Winston) { + this.config = ldapConfiguration; + this.connectorFactory = connectorFactory; + this.logger = logger; + } + + create(userDN: string, password: string): ISession { + const connector = this.connectorFactory.create(); + return new SafeSession( + new Session( + userDN, + password, + this.config, + connector, + this.logger + ) + ); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/ldap/SessionFactoryStub.spec.ts b/themes/black/server/src/lib/authentication/backends/ldap/SessionFactoryStub.spec.ts new file mode 100644 index 00000000..face3930 --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/SessionFactoryStub.spec.ts @@ -0,0 +1,16 @@ +import Sinon = require("sinon"); + +import { ISession } from "./ISession"; +import { ISessionFactory } from "./ISessionFactory"; + +export class SessionFactoryStub implements ISessionFactory { + createStub: Sinon.SinonStub; + + constructor() { + this.createStub = Sinon.stub(); + } + + create(userDN: string, password: string): ISession { + return this.createStub(userDN, password); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/ldap/SessionStub.spec.ts b/themes/black/server/src/lib/authentication/backends/ldap/SessionStub.spec.ts new file mode 100644 index 00000000..5faf2ba1 --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/SessionStub.spec.ts @@ -0,0 +1,46 @@ +import Bluebird = require("bluebird"); +import Sinon = require("sinon"); + +import { ISession } from "./ISession"; + +export class SessionStub implements ISession { + openStub: Sinon.SinonStub; + closeStub: Sinon.SinonStub; + searchUserDnStub: Sinon.SinonStub; + searchEmailsStub: Sinon.SinonStub; + searchGroupsStub: Sinon.SinonStub; + modifyPasswordStub: Sinon.SinonStub; + + constructor() { + this.openStub = Sinon.stub(); + this.closeStub = Sinon.stub(); + this.searchUserDnStub = Sinon.stub(); + this.searchEmailsStub = Sinon.stub(); + this.searchGroupsStub = Sinon.stub(); + this.modifyPasswordStub = Sinon.stub(); + } + + open(): Bluebird { + return this.openStub(); + } + + close(): Bluebird { + return this.closeStub(); + } + + searchUserDn(username: string): Bluebird { + return this.searchUserDnStub(username); + } + + searchEmails(username: string): Bluebird { + return this.searchEmailsStub(username); + } + + searchGroups(username: string): Bluebird { + return this.searchGroupsStub(username); + } + + modifyPassword(username: string, newPassword: string): Bluebird { + return this.modifyPasswordStub(username, newPassword); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/ldap/connector/Connector.ts b/themes/black/server/src/lib/authentication/backends/ldap/connector/Connector.ts new file mode 100644 index 00000000..2542ea7f --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/connector/Connector.ts @@ -0,0 +1,69 @@ +import LdapJs = require("ldapjs"); +import EventEmitter = require("events"); +import Bluebird = require("bluebird"); +import { IConnector } from "./IConnector"; +import Exceptions = require("../../../../Exceptions"); + +interface SearchEntry { + object: any; +} + +export interface ClientAsync { + on(event: string, callback: (data?: any) => void): void; + bindAsync(username: string, password: string): Bluebird; + unbindAsync(): Bluebird; + searchAsync(base: string, query: LdapJs.SearchOptions): Bluebird; + modifyAsync(userdn: string, change: LdapJs.Change): Bluebird; +} + +export class Connector implements IConnector { + private client: ClientAsync; + + constructor(url: string, ldapjs: typeof LdapJs) { + const ldapClient = ldapjs.createClient({ + url: url, + reconnect: true + }); + + /*const clientLogger = (ldapClient as any).log; + if (clientLogger) { + clientLogger.level("trace"); + }*/ + + this.client = Bluebird.promisifyAll(ldapClient) as any; + } + + bindAsync(username: string, password: string): Bluebird { + return this.client.bindAsync(username, password); + } + + unbindAsync(): Bluebird { + return this.client.unbindAsync(); + } + + searchAsync(base: string, query: any): Bluebird { + const that = this; + return this.client.searchAsync(base, query) + .then(function (res: EventEmitter) { + const doc: SearchEntry[] = []; + return new Bluebird((resolve, reject) => { + res.on("searchEntry", function (entry: SearchEntry) { + doc.push(entry.object); + }); + res.on("error", function (err: Error) { + reject(new Exceptions.LdapSearchError(err.message)); + }); + res.on("end", function () { + resolve(doc); + }); + }); + }) + .catch(function (err: Error) { + return Bluebird.reject(new Exceptions.LdapSearchError(err.message)); + }); + } + + modifyAsync(dn: string, changeRequest: any): Bluebird { + return this.client.modifyAsync(dn, changeRequest); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/ldap/connector/ConnectorFactory.ts b/themes/black/server/src/lib/authentication/backends/ldap/connector/ConnectorFactory.ts new file mode 100644 index 00000000..61fef07a --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/connector/ConnectorFactory.ts @@ -0,0 +1,18 @@ +import { IConnector } from "./IConnector"; +import { Connector } from "./Connector"; +import { LdapConfiguration } from "../../../../configuration/schema/LdapConfiguration"; +import { Ldapjs } from "Dependencies"; + +export class ConnectorFactory { + private configuration: LdapConfiguration; + private ldapjs: Ldapjs; + + constructor(configuration: LdapConfiguration, ldapjs: Ldapjs) { + this.configuration = configuration; + this.ldapjs = ldapjs; + } + + create(): IConnector { + return new Connector(this.configuration.url, this.ldapjs); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/ldap/connector/ConnectorFactoryStub.spec.ts b/themes/black/server/src/lib/authentication/backends/ldap/connector/ConnectorFactoryStub.spec.ts new file mode 100644 index 00000000..d11fa638 --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/connector/ConnectorFactoryStub.spec.ts @@ -0,0 +1,17 @@ +import BluebirdPromise = require("bluebird"); +import Sinon = require("sinon"); + +import { IConnectorFactory } from "./IConnectorFactory"; +import { IConnector } from "./IConnector"; + +export class ConnectorFactoryStub implements IConnectorFactory { + createStub: Sinon.SinonStub; + + constructor() { + this.createStub = Sinon.stub(); + } + + create(): IConnector { + return this.createStub(); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/ldap/connector/ConnectorStub.spec.ts b/themes/black/server/src/lib/authentication/backends/ldap/connector/ConnectorStub.spec.ts new file mode 100644 index 00000000..0b78225b --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/connector/ConnectorStub.spec.ts @@ -0,0 +1,34 @@ +import BluebirdPromise = require("bluebird"); +import Sinon = require("sinon"); + +import { IConnector } from "./IConnector"; + +export class ConnectorStub implements IConnector { + bindAsyncStub: Sinon.SinonStub; + unbindAsyncStub: Sinon.SinonStub; + searchAsyncStub: Sinon.SinonStub; + modifyAsyncStub: Sinon.SinonStub; + + constructor() { + this.bindAsyncStub = Sinon.stub(); + this.unbindAsyncStub = Sinon.stub(); + this.searchAsyncStub = Sinon.stub(); + this.modifyAsyncStub = Sinon.stub(); + } + + bindAsync(username: string, password: string): BluebirdPromise { + return this.bindAsyncStub(username, password); + } + + unbindAsync(): BluebirdPromise { + return this.unbindAsyncStub(); + } + + searchAsync(base: string, query: any): BluebirdPromise { + return this.searchAsyncStub(base, query); + } + + modifyAsync(dn: string, changeRequest: any): BluebirdPromise { + return this.modifyAsyncStub(dn, changeRequest); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/ldap/connector/IConnector.ts b/themes/black/server/src/lib/authentication/backends/ldap/connector/IConnector.ts new file mode 100644 index 00000000..1e63ab19 --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/connector/IConnector.ts @@ -0,0 +1,9 @@ +import Bluebird = require("bluebird"); +import EventEmitter = require("events"); + +export interface IConnector { + bindAsync(username: string, password: string): Bluebird; + unbindAsync(): Bluebird; + searchAsync(base: string, query: any): Bluebird; + modifyAsync(dn: string, changeRequest: any): Bluebird; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/backends/ldap/connector/IConnectorFactory.ts b/themes/black/server/src/lib/authentication/backends/ldap/connector/IConnectorFactory.ts new file mode 100644 index 00000000..f9ed65ef --- /dev/null +++ b/themes/black/server/src/lib/authentication/backends/ldap/connector/IConnectorFactory.ts @@ -0,0 +1,5 @@ +import { IConnector } from "./IConnector"; + +export interface IConnectorFactory { + create(): IConnector; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/totp/ITotpHandler.ts b/themes/black/server/src/lib/authentication/totp/ITotpHandler.ts new file mode 100644 index 00000000..d600d31e --- /dev/null +++ b/themes/black/server/src/lib/authentication/totp/ITotpHandler.ts @@ -0,0 +1,6 @@ +import { TOTPSecret } from "../../../../types/TOTPSecret"; + +export interface ITotpHandler { + generate(label: string, issuer: string): TOTPSecret; + validate(token: string, secret: string): boolean; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/totp/TotpHandler.spec.ts b/themes/black/server/src/lib/authentication/totp/TotpHandler.spec.ts new file mode 100644 index 00000000..67cffa63 --- /dev/null +++ b/themes/black/server/src/lib/authentication/totp/TotpHandler.spec.ts @@ -0,0 +1,39 @@ + +import { TotpHandler } from "./TotpHandler"; +import Sinon = require("sinon"); +import Speakeasy = require("speakeasy"); +import Assert = require("assert"); + +describe("authentication/totp/TotpHandler", function() { + let totpValidator: TotpHandler; + let validateStub: Sinon.SinonStub; + + beforeEach(() => { + validateStub = Sinon.stub(Speakeasy.totp, "verify"); + totpValidator = new TotpHandler(Speakeasy); + }); + + afterEach(function() { + validateStub.restore(); + }); + + it("should validate the TOTP token", function() { + const totp_secret = "NBD2ZV64R9UV1O7K"; + const token = "token"; + validateStub.withArgs({ + secret: totp_secret, + token: token, + encoding: "base32", + window: 1 + }).returns(true); + Assert(totpValidator.validate(token, totp_secret)); + }); + + it("should not validate a wrong TOTP token", function() { + const totp_secret = "NBD2ZV64R9UV1O7K"; + const token = "wrong token"; + validateStub.returns(false); + Assert(!totpValidator.validate(token, totp_secret)); + }); +}); + diff --git a/themes/black/server/src/lib/authentication/totp/TotpHandler.ts b/themes/black/server/src/lib/authentication/totp/TotpHandler.ts new file mode 100644 index 00000000..dfab502a --- /dev/null +++ b/themes/black/server/src/lib/authentication/totp/TotpHandler.ts @@ -0,0 +1,36 @@ +import { ITotpHandler } from "./ITotpHandler"; +import { TOTPSecret } from "../../../../types/TOTPSecret"; +import Speakeasy = require("speakeasy"); + +const TOTP_ENCODING = "base32"; +const WINDOW: number = 1; + +export class TotpHandler implements ITotpHandler { + private speakeasy: typeof Speakeasy; + + constructor(speakeasy: typeof Speakeasy) { + this.speakeasy = speakeasy; + } + + generate(label: string, issuer: string): TOTPSecret { + const secret = this.speakeasy.generateSecret({ + otpauth_url: false + }) as TOTPSecret; + + secret.otpauth_url = this.speakeasy.otpauthURL({ + secret: secret.ascii, + label: label, + issuer: issuer + }); + return secret; + } + + validate(token: string, secret: string): boolean { + return this.speakeasy.totp.verify({ + secret: secret, + encoding: TOTP_ENCODING, + token: token, + window: WINDOW + }); + } +} diff --git a/themes/black/server/src/lib/authentication/totp/TotpHandlerStub.spec.ts b/themes/black/server/src/lib/authentication/totp/TotpHandlerStub.spec.ts new file mode 100644 index 00000000..ea93330d --- /dev/null +++ b/themes/black/server/src/lib/authentication/totp/TotpHandlerStub.spec.ts @@ -0,0 +1,22 @@ +import Sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); +import { ITotpHandler } from "./ITotpHandler"; +import { TOTPSecret } from "../../../../types/TOTPSecret"; + +export class TotpHandlerStub implements ITotpHandler { + generateStub: Sinon.SinonStub; + validateStub: Sinon.SinonStub; + + constructor() { + this.generateStub = Sinon.stub(); + this.validateStub = Sinon.stub(); + } + + generate(label: string, issuer: string): TOTPSecret { + return this.generateStub(label, issuer); + } + + validate(token: string, secret: string): boolean { + return this.validateStub(token, secret); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/u2f/IU2fHandler.ts b/themes/black/server/src/lib/authentication/u2f/IU2fHandler.ts new file mode 100644 index 00000000..b9b7d6f2 --- /dev/null +++ b/themes/black/server/src/lib/authentication/u2f/IU2fHandler.ts @@ -0,0 +1,9 @@ +import U2f = require("u2f"); + +export interface IU2fHandler { + request(appId: string, keyHandle?: string): U2f.Request; + checkRegistration(registrationRequest: U2f.Request, registrationResponse: U2f.RegistrationData) + : U2f.RegistrationResult | U2f.Error; + checkSignature(signatureRequest: U2f.Request, signatureResponse: U2f.SignatureData, publicKey: string) + : U2f.SignatureResult | U2f.Error; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authentication/u2f/U2fHandler.ts b/themes/black/server/src/lib/authentication/u2f/U2fHandler.ts new file mode 100644 index 00000000..bf3891e5 --- /dev/null +++ b/themes/black/server/src/lib/authentication/u2f/U2fHandler.ts @@ -0,0 +1,24 @@ +import { IU2fHandler } from "./IU2fHandler"; +import U2f = require("u2f"); + +export class U2fHandler implements IU2fHandler { + private u2f: typeof U2f; + + constructor(u2f: typeof U2f) { + this.u2f = u2f; + } + + request(appId: string, keyHandle?: string): U2f.Request { + return this.u2f.request(appId, keyHandle); + } + + checkRegistration(registrationRequest: U2f.Request, registrationResponse: U2f.RegistrationData) + : U2f.RegistrationResult | U2f.Error { + return this.u2f.checkRegistration(registrationRequest, registrationResponse); + } + + checkSignature(signatureRequest: U2f.Request, signatureResponse: U2f.SignatureData, publicKey: string) + : U2f.SignatureResult | U2f.Error { + return this.u2f.checkSignature(signatureRequest, signatureResponse, publicKey); + } +} diff --git a/themes/black/server/src/lib/authentication/u2f/U2fHandlerStub.spec.ts b/themes/black/server/src/lib/authentication/u2f/U2fHandlerStub.spec.ts new file mode 100644 index 00000000..135d7eb0 --- /dev/null +++ b/themes/black/server/src/lib/authentication/u2f/U2fHandlerStub.spec.ts @@ -0,0 +1,31 @@ +import Sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); +import U2f = require("u2f"); +import { IU2fHandler } from "./IU2fHandler"; + + +export class U2fHandlerStub implements IU2fHandler { + requestStub: Sinon.SinonStub; + checkRegistrationStub: Sinon.SinonStub; + checkSignatureStub: Sinon.SinonStub; + + constructor() { + this.requestStub = Sinon.stub(); + this.checkRegistrationStub = Sinon.stub(); + this.checkSignatureStub = Sinon.stub(); + } + + request(appId: string, keyHandle?: string): U2f.Request { + return this.requestStub(appId, keyHandle); + } + + checkRegistration(registrationRequest: U2f.Request, registrationResponse: U2f.RegistrationData) + : U2f.RegistrationResult | U2f.Error { + return this.checkRegistrationStub(registrationRequest, registrationResponse); + } + + checkSignature(signatureRequest: U2f.Request, signatureResponse: U2f.SignatureData, publicKey: string) + : U2f.SignatureResult | U2f.Error { + return this.checkSignatureStub(signatureRequest, signatureResponse, publicKey); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authorization/Authorizer.spec.ts b/themes/black/server/src/lib/authorization/Authorizer.spec.ts new file mode 100644 index 00000000..58681404 --- /dev/null +++ b/themes/black/server/src/lib/authorization/Authorizer.spec.ts @@ -0,0 +1,372 @@ + +import Assert = require("assert"); +import winston = require("winston"); +import { Authorizer } from "./Authorizer"; +import { ACLConfiguration, ACLRule } from "../configuration/schema/AclConfiguration"; +import { Level } from "./Level"; + +describe("authorization/Authorizer", function () { + let authorizer: Authorizer; + let configuration: ACLConfiguration; + + describe("configuration is null", function() { + it("should allow access to anything, anywhere for anybody", function() { + configuration = undefined; + authorizer = new Authorizer(configuration, winston); + + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/"}, {user: "user1", groups: ["group1", "group2"]}), Level.BYPASS); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/abc"}, {user: "user1", groups: ["group1", "group2"]}), Level.BYPASS); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/"}, {user: "user2", groups: ["group1", "group2"]}), Level.BYPASS); + Assert.equal(authorizer.authorization({domain: "admin.example.com", resource: "/"}, {user: "user3", groups: ["group3"]}), Level.BYPASS); + }); + }); + + describe("configuration is not null", function () { + beforeEach(function () { + configuration = { + default_policy: "deny", + rules: [] + }; + authorizer = new Authorizer(configuration, winston); + }); + + describe("check access control with default policy to deny", function () { + beforeEach(function () { + configuration.default_policy = "deny"; + }); + + it("should deny access when no rule is provided", function () { + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.DENY); + }); + + it("should control access when multiple domain matcher is provided", function () { + configuration.rules = [{ + domain: "*.mail.example.com", + policy: "two_factor", + subject: "user:user1", + resources: [".*"] + }]; + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "mx1.mail.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "mx1.server.mail.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "mail.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.DENY); + }); + + it("should allow access to all resources when resources is not provided", function () { + configuration.rules = [{ + domain: "*.mail.example.com", + policy: "two_factor", + subject: "user:user1" + }]; + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "mx1.mail.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "mx1.server.mail.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "mail.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.DENY); + }); + + describe("check user rules", function () { + it("should allow access when user has a matching allowing rule", function () { + configuration.rules = [{ + domain: "home.example.com", + policy: "two_factor", + resources: [".*"], + subject: "user:user1" + }]; + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/another/resource"}, {user: "user1", groups: ["group1"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "another.home.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.DENY); + }); + + it("should deny to other users", function () { + configuration.rules = [{ + domain: "home.example.com", + policy: "two_factor", + resources: [".*"], + subject: "user:user1" + }]; + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/"}, {user: "user2", groups: ["group1"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/another/resource"}, {user: "user2", groups: ["group1"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "another.home.example.com", resource: "/"}, {user: "user2", groups: ["group1"]}), Level.DENY); + }); + + it("should allow user access only to specific resources", function () { + configuration.rules = [{ + domain: "home.example.com", + policy: "two_factor", + resources: ["/private/.*", "^/begin", "/end$"], + subject: "user:user1" + }]; + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/private"}, {user: "user1", groups: ["group1"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/private/class"}, {user: "user1", groups: ["group1"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/middle/private/class"}, {user: "user1", groups: ["group1"]}), Level.TWO_FACTOR); + + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/begin"}, {user: "user1", groups: ["group1"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/not/begin"}, {user: "user1", groups: ["group1"]}), Level.DENY); + + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/abc/end"}, {user: "user1", groups: ["group1"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/abc/end/x"}, {user: "user1", groups: ["group1"]}), Level.DENY); + }); + + it("should allow access to multiple domains", function () { + configuration.rules = [{ + domain: "home.example.com", + policy: "two_factor", + resources: [".*"], + subject: "user:user1" + }, { + domain: "home1.example.com", + policy: "one_factor", + resources: [".*"], + subject: "user:user1" + }, { + domain: "home2.example.com", + policy: "deny", + resources: [".*"], + subject: "user:user1" + }]; + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home1.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.ONE_FACTOR); + Assert.equal(authorizer.authorization({domain: "home2.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "home3.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.DENY); + }); + + it("should apply rules in order", function () { + configuration.rules = [{ + domain: "home.example.com", + policy: "one_factor", + resources: ["/my/private/resource"], + subject: "user:user1" + }, { + domain: "home.example.com", + policy: "deny", + resources: ["^/my/private/.*"], + subject: "user:user1" + }, { + domain: "home.example.com", + policy: "two_factor", + resources: ["^/my/.*"], + subject: "user:user1" + }]; + + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/my/poney"}, {user: "user1", groups: ["group1"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/my/private/duck"}, {user: "user1", groups: ["group1"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/my/private/resource"}, {user: "user1", groups: ["group1"]}), Level.ONE_FACTOR); + }); + }); + + describe("check group rules", function () { + it("should allow access when user is in group having a matching allowing rule", function () { + configuration.rules = [{ + domain: "home.example.com", + policy: "two_factor", + resources: ["^/$"], + subject: "group:group1" + }, { + domain: "home.example.com", + policy: "one_factor", + resources: ["^/test$"], + subject: "group:group2" + }, { + domain: "home.example.com", + policy: "deny", + resources: ["^/private$"], + subject: "group:group2" + }]; + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/"}, + {user: "user1", groups: ["group1", "group2", "group3"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/test"}, + {user: "user1", groups: ["group1", "group2", "group3"]}), Level.ONE_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/private"}, + {user: "user1", groups: ["group1", "group2", "group3"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "another.home.example.com", resource: "/"}, + {user: "user1", groups: ["group1", "group2", "group3"]}), Level.DENY); + }); + }); + }); + + describe("check any rules", function () { + it("should control access when any rules are defined", function () { + configuration.rules = [{ + domain: "home.example.com", + policy: "bypass", + resources: ["^/public$"] + }, { + domain: "home.example.com", + policy: "deny", + resources: ["^/private$"] + }]; + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/public"}, + {user: "user1", groups: ["group1", "group2", "group3"]}), Level.BYPASS); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/private"}, + {user: "user1", groups: ["group1", "group2", "group3"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/public"}, + {user: "user4", groups: ["group5"]}), Level.BYPASS); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/private"}, + {user: "user4", groups: ["group5"]}), Level.DENY); + }); + }); + + describe("check access control with default policy to allow", function () { + beforeEach(function () { + configuration.default_policy = "bypass"; + }); + + it("should allow access to anything when no rule is provided", function () { + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.BYPASS); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/test"}, {user: "user1", groups: ["group1"]}), Level.BYPASS); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/dev"}, {user: "user1", groups: ["group1"]}), Level.BYPASS); + }); + + it("should deny access to one resource when defined", function () { + configuration.rules = [{ + domain: "home.example.com", + policy: "deny", + resources: ["/test"], + subject: "user:user1" + }]; + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/"}, {user: "user1", groups: ["group1"]}), Level.BYPASS); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/test"}, {user: "user1", groups: ["group1"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/dev"}, {user: "user1", groups: ["group1"]}), Level.BYPASS); + }); + }); + + describe("check access control with complete use case", function () { + beforeEach(function () { + configuration.default_policy = "deny"; + }); + + it("should control access of multiple user (real use case)", function () { + // Let say we have three users: admin, john, harry. + // admin is in groups ["admins"] + // john is in groups ["dev", "admin-private"] + // harry is in groups ["dev"] + configuration.rules = [{ + domain: "home.example.com", + policy: "two_factor", + resources: ["^/public$", "^/$"] + }, { + domain: "home.example.com", + policy: "two_factor", + resources: [".*"], + subject: "group:admins" + }, { + domain: "home.example.com", + policy: "two_factor", + resources: ["^/private/?.*"], + subject: "group:admin-private" + }, { + domain: "home.example.com", + policy: "two_factor", + resources: ["^/private/john$"], + subject: "user:john" + }, { + domain: "home.example.com", + policy: "two_factor", + resources: ["^/private/harry"], + subject: "user:harry" + }]; + + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/"}, {user: "admin", groups: ["admins"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/public"}, {user: "admin", groups: ["admins"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/dev"}, {user: "admin", groups: ["admins"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/dev/bob"}, {user: "admin", groups: ["admins"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/admin"}, {user: "admin", groups: ["admins"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/private/josh"}, {user: "admin", groups: ["admins"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/private/john"}, {user: "admin", groups: ["admins"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/private/harry"}, {user: "admin", groups: ["admins"]}), Level.TWO_FACTOR); + + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/"}, {user: "john", groups: ["dev", "admin-private"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/public"}, {user: "john", groups: ["dev", "admin-private"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/dev"}, {user: "john", groups: ["dev", "admin-private"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/dev/bob"}, {user: "john", groups: ["dev", "admin-private"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/admin"}, {user: "john", groups: ["dev", "admin-private"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/private/josh"}, {user: "john", groups: ["dev", "admin-private"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/private/john"}, {user: "john", groups: ["dev", "admin-private"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/private/harry"}, {user: "john", groups: ["dev", "admin-private"]}), Level.TWO_FACTOR); + + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/"}, {user: "harry", groups: ["dev"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/public"}, {user: "harry", groups: ["dev"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/dev"}, {user: "harry", groups: ["dev"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/dev/bob"}, {user: "harry", groups: ["dev"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/admin"}, {user: "harry", groups: ["dev"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/private/josh"}, {user: "harry", groups: ["dev"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/private/john"}, {user: "harry", groups: ["dev"]}), Level.DENY); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/private/harry"}, {user: "harry", groups: ["dev"]}), Level.TWO_FACTOR); + }); + + it("should allow when allowed at group level and denied at user level", function () { + configuration.rules = [{ + domain: "home.example.com", + policy: "deny", + resources: ["^/dev/bob$"], + subject: "user:john" + }, { + domain: "home.example.com", + policy: "two_factor", + resources: ["^/dev/?.*$"], + subject: "group:dev" + }]; + + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/dev/john"}, {user: "john", groups: ["dev"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/dev/bob"}, {user: "john", groups: ["dev"]}), Level.DENY); + }); + + it("should allow access when allowed at 'any' level and denied at user level", function () { + configuration.rules = [{ + domain: "home.example.com", + policy: "deny", + resources: ["^/dev/bob$"], + subject: "user:john" + }, { + domain: "home.example.com", + policy: "two_factor", + resources: ["^/dev/?.*$"] + }]; + + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/dev/john"}, {user: "john", groups: ["dev"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/dev/bob"}, {user: "john", groups: ["dev"]}), Level.DENY); + }); + + it("should allow access when allowed at 'any' level and denied at group level", function () { + configuration.rules = [{ + domain: "home.example.com", + policy: "deny", + resources: ["^/dev/bob$"], + subject: "group:dev" + }, { + domain: "home.example.com", + policy: "two_factor", + resources: ["^/dev/?.*$"] + }]; + + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/dev/john"}, {user: "john", groups: ["dev"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/dev/bob"}, {user: "john", groups: ["dev"]}), Level.DENY); + }); + + it("should respect rules precedence", function () { + // the priority from least to most is 'default_policy', 'all', 'group', 'user' + // and the first rules in each category as a lower priority than the latest. + // You can think of it that way: they override themselves inside each category. + configuration.rules = [{ + domain: "home.example.com", + policy: "two_factor", + resources: ["^/dev/?.*$"], + subject: "user:john" + }, { + domain: "home.example.com", + policy: "deny", + resources: ["^/dev/bob$"], + subject: "group:dev" + }, { + domain: "home.example.com", + policy: "two_factor", + resources: ["^/dev/?.*$"] + }]; + + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/dev/john"}, {user: "john", groups: ["dev"]}), Level.TWO_FACTOR); + Assert.equal(authorizer.authorization({domain: "home.example.com", resource: "/dev/bob"}, {user: "john", groups: ["dev"]}), Level.TWO_FACTOR); + }); + }); + }); +}); diff --git a/themes/black/server/src/lib/authorization/Authorizer.ts b/themes/black/server/src/lib/authorization/Authorizer.ts new file mode 100644 index 00000000..889b7ec2 --- /dev/null +++ b/themes/black/server/src/lib/authorization/Authorizer.ts @@ -0,0 +1,85 @@ + +import { ACLConfiguration, ACLPolicy, ACLRule } from "../configuration/schema/AclConfiguration"; +import { IAuthorizer } from "./IAuthorizer"; +import { Winston } from "../../../types/Dependencies"; +import { MultipleDomainMatcher } from "./MultipleDomainMatcher"; +import { Level } from "./Level"; +import { Object } from "./Object"; +import { Subject } from "./Subject"; + +function MatchDomain(actualDomain: string) { + return function (rule: ACLRule): boolean { + return MultipleDomainMatcher.match(actualDomain, rule.domain); + }; +} + +function MatchResource(actualResource: string) { + return function (rule: ACLRule): boolean { + // If resources key is not provided, the rule applies to all resources. + if (!rule.resources) return true; + + for (let i = 0; i < rule.resources.length; ++i) { + const regexp = new RegExp(rule.resources[i]); + if (regexp.test(actualResource)) return true; + } + return false; + }; +} + +function MatchSubject(subject: Subject) { + return (rule: ACLRule) => { + // If no subject, matches anybody + if (!rule.subject) return true; + + if (rule.subject.startsWith("user:")) { + const ruleUser = rule.subject.split(":")[1]; + if (subject.user == ruleUser) return true; + } + + if (rule.subject.startsWith("group:")) { + const ruleGroup = rule.subject.split(":")[1]; + if (subject.groups.indexOf(ruleGroup) > -1) return true; + } + return false; + }; +} + +export class Authorizer implements IAuthorizer { + private logger: Winston; + private readonly configuration: ACLConfiguration; + + constructor(configuration: ACLConfiguration, logger_: Winston) { + this.logger = logger_; + this.configuration = configuration; + } + + private getMatchingRules(object: Object, subject: Subject): ACLRule[] { + const rules = this.configuration.rules; + if (!rules) return []; + return rules + .filter(MatchDomain(object.domain)) + .filter(MatchResource(object.resource)) + .filter(MatchSubject(subject)); + } + + private ruleToLevel(policy: string): Level { + if (policy == "bypass") { + return Level.BYPASS; + } else if (policy == "one_factor") { + return Level.ONE_FACTOR; + } else if (policy == "two_factor") { + return Level.TWO_FACTOR; + } + return Level.DENY; + } + + authorization(object: Object, subject: Subject): Level { + if (!this.configuration) return Level.BYPASS; + + const rules = this.getMatchingRules(object, subject); + + return (rules.length > 0) + ? this.ruleToLevel(rules[0].policy) // extract the policy of the first matching rule + : this.ruleToLevel(this.configuration.default_policy); // otherwise use the default policy + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authorization/AuthorizerStub.spec.ts b/themes/black/server/src/lib/authorization/AuthorizerStub.spec.ts new file mode 100644 index 00000000..9bd6f4a8 --- /dev/null +++ b/themes/black/server/src/lib/authorization/AuthorizerStub.spec.ts @@ -0,0 +1,17 @@ +import Sinon = require("sinon"); +import { IAuthorizer } from "./IAuthorizer"; +import { Level } from "./Level"; +import { Object } from "./Object"; +import { Subject } from "./Subject"; + +export class AuthorizerStub implements IAuthorizer { + authorizationMock: Sinon.SinonStub; + + constructor() { + this.authorizationMock = Sinon.stub(); + } + + authorization(object: Object, subject: Subject): Level { + return this.authorizationMock(object, subject); + } +} diff --git a/themes/black/server/src/lib/authorization/IAuthorizer.ts b/themes/black/server/src/lib/authorization/IAuthorizer.ts new file mode 100644 index 00000000..fe7ba367 --- /dev/null +++ b/themes/black/server/src/lib/authorization/IAuthorizer.ts @@ -0,0 +1,7 @@ +import { Level } from "./Level"; +import { Subject } from "./Subject"; +import { Object } from "./Object"; + +export interface IAuthorizer { + authorization(object: Object, subject: Subject): Level; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authorization/Level.ts b/themes/black/server/src/lib/authorization/Level.ts new file mode 100644 index 00000000..d1280261 --- /dev/null +++ b/themes/black/server/src/lib/authorization/Level.ts @@ -0,0 +1,6 @@ +export enum Level { + BYPASS = 0, + ONE_FACTOR = 1, + TWO_FACTOR = 2, + DENY = 3 +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authorization/MultipleDomainMatcher.ts b/themes/black/server/src/lib/authorization/MultipleDomainMatcher.ts new file mode 100644 index 00000000..64c647a4 --- /dev/null +++ b/themes/black/server/src/lib/authorization/MultipleDomainMatcher.ts @@ -0,0 +1,12 @@ + +export class MultipleDomainMatcher { + static match(domain: string, pattern: string): boolean { + if (pattern.startsWith("*") && + domain.endsWith(pattern.substr(1))) { + return true; + } + else if (domain == pattern) { + return true; + } + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authorization/Object.ts b/themes/black/server/src/lib/authorization/Object.ts new file mode 100644 index 00000000..5411b0d2 --- /dev/null +++ b/themes/black/server/src/lib/authorization/Object.ts @@ -0,0 +1,5 @@ + +export interface Object { + domain: string; + resource: string; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/authorization/Subject.ts b/themes/black/server/src/lib/authorization/Subject.ts new file mode 100644 index 00000000..310d6b4c --- /dev/null +++ b/themes/black/server/src/lib/authorization/Subject.ts @@ -0,0 +1,5 @@ + +export interface Subject { + user: string; + groups: string[]; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/ConfigurationParser.spec.ts b/themes/black/server/src/lib/configuration/ConfigurationParser.spec.ts new file mode 100644 index 00000000..60c0f618 --- /dev/null +++ b/themes/black/server/src/lib/configuration/ConfigurationParser.spec.ts @@ -0,0 +1,171 @@ +import * as Assert from "assert"; +import { Configuration } from "./schema/Configuration"; +import { ACLConfiguration } from "./schema/AclConfiguration"; +import { ConfigurationParser } from "./ConfigurationParser"; + +describe("configuration/ConfigurationParser", function () { + function buildYamlConfig(): Configuration { + const yaml_config: Configuration = { + port: 8080, + 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", + secret: "secret", + expiration: 40000 + }, + storage: { + local: { + path: "/mydirectory" + } + }, + regulation: { + max_retries: 3, + find_time: 5 * 60, + ban_time: 5 * 60 + }, + logs_level: "debug", + notifier: { + email: { + username: "user", + password: "password", + sender: "admin@example.com", + service: "gmail" + } + } + }; + return yaml_config; + } + + describe("port", function () { + it("should read the port from the yaml file", function () { + const yaml_config = buildYamlConfig(); + yaml_config.port = 7070; + const config = ConfigurationParser.parse(yaml_config); + Assert.equal(config.port, 7070); + }); + + it("should default the port to 8080 if not provided", function () { + const yaml_config = buildYamlConfig(); + delete yaml_config.port; + const config = ConfigurationParser.parse(yaml_config); + Assert.equal(config.port, 8080); + }); + }); + + describe("test session configuration", function() { + it("should get the session attributes", function () { + const yaml_config = buildYamlConfig(); + yaml_config.session = { + domain: "example.com", + secret: "secret", + expiration: 3600, + inactivity: 4000 + }; + const config = ConfigurationParser.parse(yaml_config); + Assert.equal(config.session.domain, "example.com"); + Assert.equal(config.session.secret, "secret"); + Assert.equal(config.session.expiration, 3600); + Assert.equal(config.session.inactivity, 4000); + }); + + it("should be ok not specifying inactivity", function () { + const yaml_config = buildYamlConfig(); + yaml_config.session = { + domain: "example.com", + secret: "secret", + expiration: 3600 + }; + const config = ConfigurationParser.parse(yaml_config); + Assert.equal(config.session.domain, "example.com"); + Assert.equal(config.session.secret, "secret"); + Assert.equal(config.session.expiration, 3600); + Assert.equal(config.session.inactivity, undefined); + }); + }); + + it("should get the log level", function () { + const yaml_config = buildYamlConfig(); + yaml_config.logs_level = "debug"; + const config = ConfigurationParser.parse(yaml_config); + Assert.equal(config.logs_level, "debug"); + }); + + it("should get the notifier config", function () { + const userConfig = buildYamlConfig(); + userConfig.notifier = { + email: { + username: "user", + password: "pass", + sender: "admin@example.com", + service: "gmail" + } + }; + const config = ConfigurationParser.parse(userConfig); + Assert.deepEqual(config.notifier, { + email: { + username: "user", + password: "pass", + sender: "admin@example.com", + service: "gmail" + } + }); + }); + + describe("access_control", function() { + it("should adapt access_control when it is already ok", function () { + const userConfig = buildYamlConfig(); + userConfig.access_control = { + default_policy: "deny", + rules: [{ + domain: "www.example.com", + policy: "two_factor", + subject: "user:user" + }, { + domain: "public.example.com", + policy: "two_factor" + }] + }; + const config = ConfigurationParser.parse(userConfig); + Assert.deepEqual(config.access_control, { + default_policy: "deny", + rules: [{ + domain: "www.example.com", + policy: "two_factor", + subject: "user:user" + }, { + domain: "public.example.com", + policy: "two_factor" + }] + } as ACLConfiguration); + }); + + + it("should adapt access_control when it is empty", function () { + const userConfig = buildYamlConfig(); + userConfig.access_control = {} as any; + const config = ConfigurationParser.parse(userConfig); + Assert.deepEqual(config.access_control, { + default_policy: "bypass", + rules: [] + }); + }); + }); + + describe("default_redirection_url", function() { + it("should parse default_redirection_url", function() { + const userConfig = buildYamlConfig(); + userConfig.default_redirection_url = "dummy_url"; + const config = ConfigurationParser.parse(userConfig); + Assert.deepEqual(config.default_redirection_url, "dummy_url"); + }); + }); +}); diff --git a/themes/black/server/src/lib/configuration/ConfigurationParser.ts b/themes/black/server/src/lib/configuration/ConfigurationParser.ts new file mode 100644 index 00000000..d92d163c --- /dev/null +++ b/themes/black/server/src/lib/configuration/ConfigurationParser.ts @@ -0,0 +1,39 @@ + +import * as ObjectPath from "object-path"; +import { Configuration, complete } from "./schema/Configuration"; +import Ajv = require("ajv"); +import Path = require("path"); +import Util = require("util"); + +export class ConfigurationParser { + private static parseTypes(configuration: Configuration): string[] { + const schema = require(Path.resolve(__dirname, "./Configuration.schema.json")); + const ajv = new Ajv({ + allErrors: true, + missingRefs: "fail" + }); + ajv.addMetaSchema(require("ajv/lib/refs/json-schema-draft-06.json")); + const valid = ajv.validate(schema, configuration); + if (!valid) + return ajv.errors.map( + (e: Ajv.ErrorObject) => { return ajv.errorsText([e]); }); + return []; + } + + static parse(configuration: Configuration): Configuration { + const validationErrors = this.parseTypes(configuration); + if (validationErrors.length > 0) { + validationErrors.forEach((e: string) => { console.log(e); }); + throw new Error("Malformed configuration (schema). Please double-check your configuration file."); + } + + const [newConfiguration, completionErrors] = complete(configuration); + + if (completionErrors.length > 0) { + completionErrors.forEach((e: string) => { console.log(e); }); + throw new Error("Malformed configuration (validator). Please double-check your configuration file."); + } + return newConfiguration; + } +} + diff --git a/themes/black/server/src/lib/configuration/SessionConfigurationBuilder.spec.ts b/themes/black/server/src/lib/configuration/SessionConfigurationBuilder.spec.ts new file mode 100644 index 00000000..d4a3093e --- /dev/null +++ b/themes/black/server/src/lib/configuration/SessionConfigurationBuilder.spec.ts @@ -0,0 +1,149 @@ +import { SessionConfigurationBuilder } from "./SessionConfigurationBuilder"; +import { Configuration } from "./schema/Configuration"; +import { GlobalDependencies } from "../../../types/Dependencies"; + +import ExpressSession = require("express-session"); +import ConnectRedis = require("connect-redis"); +import Sinon = require("sinon"); +import Assert = require("assert"); + +describe("configuration/SessionConfigurationBuilder", function () { + const configuration: Configuration = { + access_control: { + default_policy: "deny", + rules: [] + }, + totp: { + issuer: "authelia.com" + }, + 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: { + filesystem: { + filename: "/test" + } + }, + port: 8080, + session: { + name: "authelia_session", + domain: "example.com", + expiration: 3600, + secret: "secret" + }, + regulation: { + max_retries: 3, + ban_time: 5 * 60, + find_time: 5 * 60 + }, + storage: { + local: { + in_memory: true + } + } + }; + + const deps: GlobalDependencies = { + ConnectRedis: Sinon.spy() as any, + ldapjs: Sinon.spy() as any, + nedb: Sinon.spy() as any, + session: Sinon.spy() as any, + speakeasy: Sinon.spy() as any, + u2f: Sinon.spy() as any, + winston: Sinon.spy() as any, + Redis: Sinon.spy() as any + }; + + it("should return session options without redis options", function () { + const options = SessionConfigurationBuilder.build(configuration, deps); + const expectedOptions = { + name: "authelia_session", + secret: "secret", + resave: false, + saveUninitialized: true, + cookie: { + secure: true, + httpOnly: true, + maxAge: 3600, + domain: "example.com" + } + }; + + Assert.deepEqual(expectedOptions, options); + }); + + it("should return session options with redis options", function () { + configuration.session["redis"] = { + host: "redis.example.com", + port: 6379 + }; + const RedisStoreMock = Sinon.spy(); + const redisClient = Sinon.mock().returns({ on: Sinon.spy() }); + + deps.ConnectRedis = Sinon.stub().returns(RedisStoreMock) as any; + deps.Redis = { + createClient: Sinon.mock().returns(redisClient) + } as any; + + const options = SessionConfigurationBuilder.build(configuration, deps); + + const expectedOptions: ExpressSession.SessionOptions = { + secret: "secret", + resave: false, + saveUninitialized: true, + name: "authelia_session", + cookie: { + secure: true, + httpOnly: true, + maxAge: 3600, + domain: "example.com" + }, + store: Sinon.match.object as any + }; + + Assert((deps.ConnectRedis as Sinon.SinonStub).calledWith(deps.session)); + Assert.equal(options.secret, expectedOptions.secret); + Assert.equal(options.resave, expectedOptions.resave); + Assert.equal(options.saveUninitialized, expectedOptions.saveUninitialized); + Assert.deepEqual(options.cookie, expectedOptions.cookie); + Assert(options.store != undefined); + }); + + it("should return session options with redis password", function () { + configuration.session["redis"] = { + host: "redis.example.com", + port: 6379, + password: "authelia_pass" + }; + const RedisStoreMock = Sinon.spy(); + const redisClient = Sinon.mock().returns({ on: Sinon.spy() }); + const createClientStub = Sinon.stub(); + + deps.ConnectRedis = Sinon.stub().returns(RedisStoreMock) as any; + deps.Redis = { + createClient: createClientStub + } as any; + + createClientStub.returns(redisClient); + + const options = SessionConfigurationBuilder.build(configuration, deps); + + Assert(createClientStub.calledWith({ + host: "redis.example.com", + port: 6379, + password: "authelia_pass" + })); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/SessionConfigurationBuilder.ts b/themes/black/server/src/lib/configuration/SessionConfigurationBuilder.ts new file mode 100644 index 00000000..6ce643d9 --- /dev/null +++ b/themes/black/server/src/lib/configuration/SessionConfigurationBuilder.ts @@ -0,0 +1,52 @@ +import ExpressSession = require("express-session"); +import Redis = require("redis"); + +import { Configuration } from "./schema/Configuration"; +import { GlobalDependencies } from "../../../types/Dependencies"; +import { RedisStoreOptions } from "connect-redis"; + +export class SessionConfigurationBuilder { + + static build(configuration: Configuration, deps: GlobalDependencies): ExpressSession.SessionOptions { + const sessionOptions: ExpressSession.SessionOptions = { + name: configuration.session.name, + secret: configuration.session.secret, + resave: false, + saveUninitialized: true, + cookie: { + secure: true, + httpOnly: true, + maxAge: configuration.session.expiration, + domain: configuration.session.domain + }, + }; + + if (configuration.session.redis) { + let redisOptions; + const options: Redis.ClientOpts = { + host: configuration.session.redis.host, + port: configuration.session.redis.port + }; + + if (configuration.session.redis.password) { + options["password"] = configuration.session.redis.password; + } + const client = deps.Redis.createClient(options); + + client.on("error", function (err: Error) { + console.error("Redis error:", err); + }); + + redisOptions = { + client: client, + logErrors: true + }; + + if (redisOptions) { + const RedisStore = deps.ConnectRedis(deps.session); + sessionOptions.store = new RedisStore(redisOptions); + } + } + return sessionOptions; + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/schema/AclConfiguration.spec.ts b/themes/black/server/src/lib/configuration/schema/AclConfiguration.spec.ts new file mode 100644 index 00000000..d1e2a03a --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/AclConfiguration.spec.ts @@ -0,0 +1,34 @@ +import { ACLConfiguration, complete } from "./AclConfiguration"; +import Assert = require("assert"); + +describe("configuration/schema/AclConfiguration", function() { + it("should complete ACLConfiguration", function() { + const configuration: ACLConfiguration = {}; + const [newConfiguration, errors] = complete(configuration); + + Assert.deepEqual(newConfiguration.default_policy, "bypass"); + Assert.deepEqual(newConfiguration.rules, []); + }); + + it("should return errors when subject is not good", function() { + const configuration: ACLConfiguration = { + default_policy: "deny", + rules: [{ + domain: "dev.example.com", + subject: "user:abc", + policy: "bypass" + }, { + domain: "dev.example.com", + subject: "user:def", + policy: "bypass" + }, { + domain: "dev.example.com", + subject: "badkey:abc", + policy: "bypass" + }] + }; + const [newConfiguration, errors] = complete(configuration); + + Assert.deepEqual(errors, ["Rule 2 has wrong subject. It should be starting with user: or group:."]); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/schema/AclConfiguration.ts b/themes/black/server/src/lib/configuration/schema/AclConfiguration.ts new file mode 100644 index 00000000..40401dd6 --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/AclConfiguration.ts @@ -0,0 +1,41 @@ + +export type ACLPolicy = "deny" | "bypass" | "one_factor" | "two_factor"; + +export type ACLRule = { + domain: string; + resources?: string[]; + subject?: string; + policy: ACLPolicy; +}; + +export interface ACLConfiguration { + default_policy?: ACLPolicy; + rules?: ACLRule[]; +} + +export function complete(configuration: ACLConfiguration): [ACLConfiguration, string[]] { + const newConfiguration: ACLConfiguration = (configuration) + ? JSON.parse(JSON.stringify(configuration)) : {}; + + if (!newConfiguration.default_policy) { + newConfiguration.default_policy = "bypass"; + } + + if (!newConfiguration.rules) { + newConfiguration.rules = []; + } + + if (newConfiguration.rules.length > 0) { + const errors: string[] = []; + newConfiguration.rules.forEach((r, idx) => { + if (r.subject && !r.subject.match(/^(user|group):[a-zA-Z0-9]+$/)) { + errors.push(`Rule ${idx} has wrong subject. It should be starting with user: or group:.`); + } + }); + if (errors.length > 0) { + return [newConfiguration, errors]; + } + } + + return [newConfiguration, []]; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.spec.ts b/themes/black/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.spec.ts new file mode 100644 index 00000000..3ca86381 --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.spec.ts @@ -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`"); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.ts b/themes/black/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.ts new file mode 100644 index 00000000..7f77f894 --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.ts @@ -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]; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/schema/Configuration.ts b/themes/black/server/src/lib/configuration/schema/Configuration.ts new file mode 100644 index 00000000..8d16a5fb --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/Configuration.ts @@ -0,0 +1,68 @@ +import { ACLConfiguration, complete as AclConfigurationComplete } from "./AclConfiguration"; +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"; +import { StorageConfiguration, complete as StorageConfigurationComplete } from "./StorageConfiguration"; +import { TotpConfiguration, complete as TotpConfigurationComplete } from "./TotpConfiguration"; + +export interface Configuration { + access_control?: ACLConfiguration; + authentication_backend: AuthenticationBackendConfiguration; + default_redirection_url?: string; + logs_level?: string; + notifier?: NotifierConfiguration; + port?: number; + regulation?: RegulationConfiguration; + session?: SessionConfiguration; + storage?: StorageConfiguration; + totp?: TotpConfiguration; +} + +export function complete( + configuration: Configuration): + [Configuration, string[]] { + + const newConfiguration: Configuration = JSON.parse( + JSON.stringify(configuration)); + const errors: string[] = []; + + const [acls, aclsErrors] = AclConfigurationComplete( + newConfiguration.access_control); + + newConfiguration.access_control = acls; + if (aclsErrors.length > 0) { + errors.concat(aclsErrors); + } + + const [backend, error] = + AuthenticationBackendComplete( + newConfiguration.authentication_backend); + + if (error) errors.push(error); + newConfiguration.authentication_backend = backend; + + if (!newConfiguration.logs_level) { + newConfiguration.logs_level = "info"; + } + + const [notifier, notifierError] = NotifierConfigurationComplete( + newConfiguration.notifier); + newConfiguration.notifier = notifier; + if (notifierError) errors.push(notifierError); + + if (!newConfiguration.port) { + newConfiguration.port = 8080; + } + + newConfiguration.regulation = RegulationConfigurationComplete( + newConfiguration.regulation); + newConfiguration.session = SessionConfigurationComplete( + newConfiguration.session); + newConfiguration.storage = StorageConfigurationComplete( + newConfiguration.storage); + newConfiguration.totp = TotpConfigurationComplete( + newConfiguration.totp); + + return [newConfiguration, errors]; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/schema/FileUsersDatabaseConfiguration.ts b/themes/black/server/src/lib/configuration/schema/FileUsersDatabaseConfiguration.ts new file mode 100644 index 00000000..d19002ba --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/FileUsersDatabaseConfiguration.ts @@ -0,0 +1,4 @@ + +export interface FileUsersDatabaseConfiguration { + path: string; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/schema/LdapConfiguration.spec.ts b/themes/black/server/src/lib/configuration/schema/LdapConfiguration.spec.ts new file mode 100644 index 00000000..cc73d108 --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/LdapConfiguration.spec.ts @@ -0,0 +1,25 @@ +import Assert = require("assert"); +import { LdapConfiguration, complete } from "./LdapConfiguration"; + +describe("configuration/schema/AuthenticationMethodsConfiguration", function() { + it("should ensure at least one key is provided", function() { + const configuration: LdapConfiguration = { + url: "ldap.example.com", + base_dn: "dc=example,dc=com", + user: "admin", + password: "password" + }; + const newConfiguration = complete(configuration); + + Assert.deepEqual(newConfiguration, { + url: "ldap.example.com", + base_dn: "dc=example,dc=com", + user: "admin", + password: "password", + users_filter: "cn={0}", + group_name_attribute: "cn", + groups_filter: "member={dn}", + mail_attribute: "mail" + }); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/schema/LdapConfiguration.ts b/themes/black/server/src/lib/configuration/schema/LdapConfiguration.ts new file mode 100644 index 00000000..5dacb939 --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/LdapConfiguration.ts @@ -0,0 +1,40 @@ +import Util = require("util"); + +export interface LdapConfiguration { + url: string; + base_dn: string; + + additional_users_dn?: string; + users_filter?: string; + + additional_groups_dn?: string; + groups_filter?: string; + + group_name_attribute?: string; + mail_attribute?: string; + + user: string; // admin username + password: string; // admin password +} + +export function complete(configuration: LdapConfiguration): LdapConfiguration { + const newConfiguration: LdapConfiguration = (configuration) ? JSON.parse(JSON.stringify(configuration)) : {}; + + if (!newConfiguration.users_filter) { + newConfiguration.users_filter = "cn={0}"; + } + + if (!newConfiguration.groups_filter) { + newConfiguration.groups_filter = "member={dn}"; + } + + if (!newConfiguration.group_name_attribute) { + newConfiguration.group_name_attribute = "cn"; + } + + if (!newConfiguration.mail_attribute) { + newConfiguration.mail_attribute = "mail"; + } + + return newConfiguration; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/schema/NotifierConfiguration.spec.ts b/themes/black/server/src/lib/configuration/schema/NotifierConfiguration.spec.ts new file mode 100644 index 00000000..6c576e8e --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/NotifierConfiguration.spec.ts @@ -0,0 +1,40 @@ +import Assert = require("assert"); +import { NotifierConfiguration, complete } from "./NotifierConfiguration"; + +describe("configuration/schema/NotifierConfiguration", function() { + it("should use a default notifier when none is provided", function() { + const configuration: NotifierConfiguration = {}; + const [newConfiguration, error] = complete(configuration); + + Assert.deepEqual(newConfiguration.filesystem, {filename: "/tmp/authelia/notification.txt"}); + }); + + it("should ensure correct key is provided", function() { + const configuration = { + abc: "badvalue" + }; + const [newConfiguration, error] = complete(configuration as any); + + Assert.equal(error, "Notifier must have one of the following keys: 'filesystem', 'email' or 'smtp'"); + }); + + it("should ensure there is no more than one key", function() { + const configuration: NotifierConfiguration = { + smtp: { + host: "smtp.example.com", + port: 25, + secure: false, + sender: "test@example.com" + }, + email: { + username: "test", + password: "test", + sender: "test@example.com", + service: "gmail" + } + }; + const [newConfiguration, error] = complete(configuration); + + Assert.equal(error, "Notifier must have one of the following keys: 'filesystem', 'email' or 'smtp'"); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/schema/NotifierConfiguration.ts b/themes/black/server/src/lib/configuration/schema/NotifierConfiguration.ts new file mode 100644 index 00000000..7bcce15c --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/NotifierConfiguration.ts @@ -0,0 +1,45 @@ + +export interface EmailNotifierConfiguration { + username: string; + password: string; + sender: string; + service: string; +} + +export interface SmtpNotifierConfiguration { + username?: string; + password?: string; + host: string; + port: number; + secure: boolean; + sender: string; +} + +export interface FileSystemNotifierConfiguration { + filename: string; +} + +export interface NotifierConfiguration { + email?: EmailNotifierConfiguration; + smtp?: SmtpNotifierConfiguration; + filesystem?: FileSystemNotifierConfiguration; +} + +export function complete(configuration: NotifierConfiguration): [NotifierConfiguration, string] { + const newConfiguration: NotifierConfiguration = (configuration) ? JSON.parse(JSON.stringify(configuration)) : {}; + + if (Object.keys(newConfiguration).length == 0) + newConfiguration.filesystem = { filename: "/tmp/authelia/notification.txt" }; + + const ERROR = "Notifier must have one of the following keys: 'filesystem', 'email' or 'smtp'"; + + if (Object.keys(newConfiguration).length != 1) + return [newConfiguration, ERROR]; + + const key = Object.keys(newConfiguration)[0]; + + if (key != "filesystem" && key != "smtp" && key != "email") + return [newConfiguration, ERROR]; + + return [newConfiguration, undefined]; +} diff --git a/themes/black/server/src/lib/configuration/schema/RegulationConfiguration.spec.ts b/themes/black/server/src/lib/configuration/schema/RegulationConfiguration.spec.ts new file mode 100644 index 00000000..dce2caf4 --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/RegulationConfiguration.spec.ts @@ -0,0 +1,13 @@ +import Assert = require("assert"); +import { RegulationConfiguration, complete } from "./RegulationConfiguration"; + +describe("configuration/schema/RegulationConfiguration", function() { + it("should return default regulation configuration", function() { + const configuration: RegulationConfiguration = {}; + const newConfiguration = complete(configuration); + + Assert.equal(newConfiguration.ban_time, 300); + Assert.equal(newConfiguration.find_time, 120); + Assert.equal(newConfiguration.max_retries, 3); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/schema/RegulationConfiguration.ts b/themes/black/server/src/lib/configuration/schema/RegulationConfiguration.ts new file mode 100644 index 00000000..117463f4 --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/RegulationConfiguration.ts @@ -0,0 +1,23 @@ +export interface RegulationConfiguration { + max_retries?: number; + find_time?: number; + ban_time?: number; +} + +export function complete(configuration: RegulationConfiguration): RegulationConfiguration { + const newConfiguration: RegulationConfiguration = (configuration) ? JSON.parse(JSON.stringify(configuration)) : {}; + + if (!newConfiguration.max_retries) { + newConfiguration.max_retries = 3; + } + + if (!newConfiguration.find_time) { + newConfiguration.find_time = 120; // seconds + } + + if (!newConfiguration.ban_time) { + newConfiguration.ban_time = 300; // seconds + } + + return newConfiguration; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/schema/SessionConfiguration.spec.ts b/themes/black/server/src/lib/configuration/schema/SessionConfiguration.spec.ts new file mode 100644 index 00000000..e5401083 --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/SessionConfiguration.spec.ts @@ -0,0 +1,16 @@ +import Assert = require("assert"); +import { SessionConfiguration, complete } from "./SessionConfiguration"; + +describe("configuration/schema/SessionConfiguration", function() { + it("should return default regulation configuration", function() { + const configuration: SessionConfiguration = { + domain: "example.com", + secret: "unsecure_secret" + }; + const newConfiguration = complete(configuration); + + Assert.equal(newConfiguration.name, 'authelia_session'); + Assert.equal(newConfiguration.expiration, 3600000); + Assert.equal(newConfiguration.inactivity, undefined); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/schema/SessionConfiguration.ts b/themes/black/server/src/lib/configuration/schema/SessionConfiguration.ts new file mode 100644 index 00000000..2c88bb21 --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/SessionConfiguration.ts @@ -0,0 +1,32 @@ +export interface SessionRedisOptions { + host: string; + port: number; + password?: string; +} + +export interface SessionConfiguration { + name?: string; + domain: string; + secret: string; + expiration?: number; + inactivity?: number; + redis?: SessionRedisOptions; +} + +export function complete(configuration: SessionConfiguration): SessionConfiguration { + const newConfiguration: SessionConfiguration = (configuration) ? JSON.parse(JSON.stringify(configuration)) : {}; + + if (!newConfiguration.name) { + newConfiguration.name = "authelia_session"; + } + + if (!newConfiguration.expiration) { + newConfiguration.expiration = 3600000; // 1 hour + } + + if (!newConfiguration.inactivity) { + newConfiguration.inactivity = undefined; // disabled + } + + return newConfiguration; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/schema/StorageConfiguration.spec.ts b/themes/black/server/src/lib/configuration/schema/StorageConfiguration.spec.ts new file mode 100644 index 00000000..9d02a11b --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/StorageConfiguration.spec.ts @@ -0,0 +1,15 @@ +import Assert = require("assert"); +import { StorageConfiguration, complete } from "./StorageConfiguration"; + +describe("configuration/schema/StorageConfiguration", function() { + it("should return default regulation configuration", function() { + const configuration: StorageConfiguration = {}; + const newConfiguration = complete(configuration); + + Assert.deepEqual(newConfiguration, { + local: { + in_memory: true + } + }); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/schema/StorageConfiguration.ts b/themes/black/server/src/lib/configuration/schema/StorageConfiguration.ts new file mode 100644 index 00000000..47e356ef --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/StorageConfiguration.ts @@ -0,0 +1,30 @@ +export interface MongoStorageConfiguration { + url: string; + database: string; + auth?: { + username: string; + password: string; + }; +} + +export interface LocalStorageConfiguration { + path?: string; + in_memory?: boolean; +} + +export interface StorageConfiguration { + local?: LocalStorageConfiguration; + mongo?: MongoStorageConfiguration; +} + +export function complete(configuration: StorageConfiguration): StorageConfiguration { + const newConfiguration: StorageConfiguration = (configuration) ? JSON.parse(JSON.stringify(configuration)) : {}; + + if (!newConfiguration.local && !newConfiguration.mongo) { + newConfiguration.local = { + in_memory: true + }; + } + + return newConfiguration; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/schema/TotpConfiguration.ts b/themes/black/server/src/lib/configuration/schema/TotpConfiguration.ts new file mode 100644 index 00000000..68313563 --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/TotpConfiguration.ts @@ -0,0 +1,13 @@ +export interface TotpConfiguration { + issuer: string; +} + +export function complete(configuration: TotpConfiguration): TotpConfiguration { + const newConfiguration: TotpConfiguration = (configuration) ? JSON.parse(JSON.stringify(configuration)) : {}; + + if (!newConfiguration.issuer) { + newConfiguration.issuer = "authelia.com"; + } + + return newConfiguration; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/configuration/schema/UserDatabaseConfiguration.ts b/themes/black/server/src/lib/configuration/schema/UserDatabaseConfiguration.ts new file mode 100644 index 00000000..8008b483 --- /dev/null +++ b/themes/black/server/src/lib/configuration/schema/UserDatabaseConfiguration.ts @@ -0,0 +1,9 @@ + +export interface UserInfo { + username: string; + password_hash: string; + email: string; + groups?: string[]; +} + +export type UserDatabaseConfiguration = UserInfo[]; \ No newline at end of file diff --git a/themes/black/server/src/lib/connectors/mongo/IMongoClient.d.ts b/themes/black/server/src/lib/connectors/mongo/IMongoClient.d.ts new file mode 100644 index 00000000..36cb4b8b --- /dev/null +++ b/themes/black/server/src/lib/connectors/mongo/IMongoClient.d.ts @@ -0,0 +1,6 @@ +import MongoDB = require("mongodb"); +import Bluebird = require("bluebird"); + +export interface IMongoClient { + collection(name: string): Bluebird +} \ No newline at end of file diff --git a/themes/black/server/src/lib/connectors/mongo/MongoClient.spec.ts b/themes/black/server/src/lib/connectors/mongo/MongoClient.spec.ts new file mode 100644 index 00000000..ca0c6859 --- /dev/null +++ b/themes/black/server/src/lib/connectors/mongo/MongoClient.spec.ts @@ -0,0 +1,119 @@ +import Assert = require("assert"); +import Bluebird = require("bluebird"); +import MongoDB = require("mongodb"); +import Sinon = require("sinon"); + +import { MongoClient } from "./MongoClient"; +import { GlobalLoggerStub } from "../../logging/GlobalLoggerStub.spec"; +import { MongoStorageConfiguration } from "../../configuration/schema/StorageConfiguration"; + +describe("connectors/mongo/MongoClient", function () { + let MongoClientStub: any; + let mongoClientStub: any; + let mongoDatabaseStub: any; + let logger: GlobalLoggerStub = new GlobalLoggerStub(); + + const configuration: MongoStorageConfiguration = { + url: "mongo://url", + database: "databasename" + }; + + describe("connection", () => { + before(() => { + mongoClientStub = { + db: Sinon.stub() + }; + mongoDatabaseStub = { + on: Sinon.stub(), + collection: Sinon.stub() + } + MongoClientStub = Sinon.stub( + MongoDB.MongoClient, "connect"); + MongoClientStub.yields( + undefined, mongoClientStub); + mongoClientStub.db.returns( + mongoDatabaseStub); + }); + + after(() => { + MongoClientStub.restore(); + }); + + it("should use credentials from configuration", () => { + configuration.auth = { + username: "authelia", + password: "authelia_pass" + }; + + const client = new MongoClient(configuration, logger); + return client.collection("test") + .then(() => { + Assert(MongoClientStub.calledWith("mongo://url", { + auth: { + user: "authelia", + password: "authelia_pass" + } + })) + }); + }); + }); + + describe("collection", () => { + before(function() { + mongoClientStub = { + db: Sinon.stub() + }; + mongoDatabaseStub = { + on: Sinon.stub(), + collection: Sinon.stub() + } + }); + + describe("Connection to mongo is ok", function() { + before(function () { + MongoClientStub = Sinon.stub( + MongoDB.MongoClient, "connect"); + MongoClientStub.yields( + undefined, mongoClientStub); + mongoClientStub.db.returns( + mongoDatabaseStub); + }); + + after(function () { + MongoClientStub.restore(); + }); + + it("should create a collection", function () { + const COLLECTION_NAME = "mycollection"; + const client = new MongoClient(configuration, logger); + + mongoDatabaseStub.collection.returns("COL"); + return client.collection(COLLECTION_NAME) + .then((collection) => mongoDatabaseStub.collection.calledWith(COLLECTION_NAME)); + }); + }); + + describe("Connection to mongo is broken", function() { + before(function () { + MongoClientStub = Sinon.stub( + MongoDB.MongoClient, "connect"); + MongoClientStub.yields( + new Error("Failed connection"), undefined); + }); + + after(function () { + MongoClientStub.restore(); + }); + + it("should fail creating the collection", function() { + const COLLECTION_NAME = "mycollection"; + const client = new MongoClient(configuration, logger); + + mongoDatabaseStub.collection.returns("COL"); + return client.collection(COLLECTION_NAME) + .then((collection) => Bluebird.reject(new Error("should not be here."))) + .catch((err) => Bluebird.resolve()); + }); + }) + }); +}); diff --git a/themes/black/server/src/lib/connectors/mongo/MongoClient.ts b/themes/black/server/src/lib/connectors/mongo/MongoClient.ts new file mode 100644 index 00000000..d15731e9 --- /dev/null +++ b/themes/black/server/src/lib/connectors/mongo/MongoClient.ts @@ -0,0 +1,76 @@ + +import MongoDB = require("mongodb"); +import { IMongoClient } from "./IMongoClient"; +import Bluebird = require("bluebird"); +import { AUTHENTICATION_FAILED } from "../../../../../shared/UserMessages"; +import { IGlobalLogger } from "../../logging/IGlobalLogger"; +import { MongoStorageConfiguration } from "../../configuration/schema/StorageConfiguration"; + +export class MongoClient implements IMongoClient { + private configuration: MongoStorageConfiguration; + + private database: MongoDB.Db; + private client: MongoDB.MongoClient; + private logger: IGlobalLogger; + + constructor( + configuration: MongoStorageConfiguration, + logger: IGlobalLogger) { + + this.configuration = configuration; + this.logger = logger; + } + + connect(): Bluebird { + const that = this; + const options: MongoDB.MongoClientOptions = {}; + if (that.configuration.auth) { + options["auth"] = { + user: that.configuration.auth.username, + password: that.configuration.auth.password + }; + } + + return new Bluebird((resolve, reject) => { + MongoDB.MongoClient.connect( + this.configuration.url, + options, + function(err, client) { + if (err) { + reject(err); + return; + } + resolve(client); + }); + }) + .then(function (client: MongoDB.MongoClient) { + that.database = client.db(that.configuration.database); + that.database.on("close", () => { + that.logger.info("[MongoClient] Lost connection."); + }); + that.database.on("reconnect", () => { + that.logger.info("[MongoClient] Reconnected."); + }); + that.client = client; + }); + } + + close(): Bluebird { + if (this.client) { + this.client.close(); + this.database = undefined; + this.client = undefined; + } + return Bluebird.resolve(); + } + + collection(name: string): Bluebird { + if (!this.client) { + const that = this; + return this.connect() + .then(() => Bluebird.resolve(that.database.collection(name))); + } + + return Bluebird.resolve(this.database.collection(name)); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/connectors/mongo/MongoClientStub.spec.ts b/themes/black/server/src/lib/connectors/mongo/MongoClientStub.spec.ts new file mode 100644 index 00000000..1cfd48e3 --- /dev/null +++ b/themes/black/server/src/lib/connectors/mongo/MongoClientStub.spec.ts @@ -0,0 +1,16 @@ +import Sinon = require("sinon"); +import MongoDB = require("mongodb"); +import Bluebird = require("bluebird"); +import { IMongoClient } from "../../../../src/lib/connectors/mongo/IMongoClient"; + +export class MongoClientStub implements IMongoClient { + public collectionStub: Sinon.SinonStub; + + constructor() { + this.collectionStub = Sinon.stub(); + } + + collection(name: string): Bluebird { + return this.collectionStub(name); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/logging/GlobalLogger.ts b/themes/black/server/src/lib/logging/GlobalLogger.ts new file mode 100644 index 00000000..4da7acf4 --- /dev/null +++ b/themes/black/server/src/lib/logging/GlobalLogger.ts @@ -0,0 +1,34 @@ +import { IGlobalLogger } from "./IGlobalLogger"; +import Util = require("util"); +import Express = require("express"); +import Winston = require("winston"); + +declare module "express" { + interface Request { + id: string; + } +} + +export class GlobalLogger implements IGlobalLogger { + private winston: typeof Winston; + constructor(winston: typeof Winston) { + this.winston = winston; + } + + private buildMessage(message: string, ...args: any[]): string { + return Util.format("date='%s' message='%s'", new Date(), + Util.format(message, ...args)); + } + + info(message: string, ...args: any[]): void { + this.winston.info(this.buildMessage(message, ...args)); + } + + debug(message: string, ...args: any[]): void { + this.winston.debug(this.buildMessage(message, ...args)); + } + + error(message: string, ...args: any[]): void { + this.winston.debug(this.buildMessage(message, ...args)); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/logging/GlobalLoggerStub.spec.ts b/themes/black/server/src/lib/logging/GlobalLoggerStub.spec.ts new file mode 100644 index 00000000..d4bb1371 --- /dev/null +++ b/themes/black/server/src/lib/logging/GlobalLoggerStub.spec.ts @@ -0,0 +1,38 @@ +import Sinon = require("sinon"); +import { GlobalLogger } from "./GlobalLogger"; +import Winston = require("winston"); +import Express = require("express"); +import { IGlobalLogger } from "./IGlobalLogger"; + +export class GlobalLoggerStub implements IGlobalLogger { + infoStub: Sinon.SinonStub; + debugStub: Sinon.SinonStub; + errorStub: Sinon.SinonStub; + private globalLogger: IGlobalLogger; + + constructor(enableLogging?: boolean) { + this.infoStub = Sinon.stub(); + this.debugStub = Sinon.stub(); + this.errorStub = Sinon.stub(); + if (enableLogging) + this.globalLogger = new GlobalLogger(Winston); + } + + info(message: string, ...args: any[]): void { + if (this.globalLogger) + this.globalLogger.info(message, ...args); + this.infoStub(message, ...args); + } + + debug(message: string, ...args: any[]): void { + if (this.globalLogger) + this.globalLogger.info(message, ...args); + this.debugStub(message, ...args); + } + + error(message: string, ...args: any[]): void { + if (this.globalLogger) + this.globalLogger.info(message, ...args); + this.errorStub(message, ...args); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/logging/IGlobalLogger.ts b/themes/black/server/src/lib/logging/IGlobalLogger.ts new file mode 100644 index 00000000..548515ec --- /dev/null +++ b/themes/black/server/src/lib/logging/IGlobalLogger.ts @@ -0,0 +1,5 @@ +export interface IGlobalLogger { + info(message: string, ...args: any[]): void; + debug(message: string, ...args: any[]): void; + error(message: string, ...args: any[]): void; +} diff --git a/themes/black/server/src/lib/logging/IRequestLogger.ts b/themes/black/server/src/lib/logging/IRequestLogger.ts new file mode 100644 index 00000000..126a601f --- /dev/null +++ b/themes/black/server/src/lib/logging/IRequestLogger.ts @@ -0,0 +1,7 @@ +import Express = require("express"); + +export interface IRequestLogger { + info(req: Express.Request, message: string, ...args: any[]): void; + debug(req: Express.Request, message: string, ...args: any[]): void; + error(req: Express.Request, message: string, ...args: any[]): void; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/logging/RequestLogger.ts b/themes/black/server/src/lib/logging/RequestLogger.ts new file mode 100644 index 00000000..c45c6601 --- /dev/null +++ b/themes/black/server/src/lib/logging/RequestLogger.ts @@ -0,0 +1,45 @@ +import { IRequestLogger } from "./IRequestLogger"; +import Util = require("util"); +import Express = require("express"); +import Winston = require("winston"); + +declare module "express" { + interface Request { + id: string; + } +} + +export class RequestLogger implements IRequestLogger { + private winston: typeof Winston; + + constructor(winston: typeof Winston) { + this.winston = winston; + } + + private formatHeader(req: Express.Request) { + const clientIP = req.ip; // The IP of the original client going through the proxy chain. + return Util.format("date='%s' method='%s', path='%s' requestId='%s' sessionId='%s' ip='%s'", + new Date(), req.method, req.path, req.id, req.sessionID, clientIP); + } + + private formatBody(message: string) { + return Util.format("message='%s'", message); + } + + private formatMessage(req: Express.Request, message: string) { + return Util.format("%s %s", this.formatHeader(req), + this.formatBody(message)); + } + + info(req: Express.Request, message: string, ...args: any[]): void { + this.winston.info(this.formatMessage(req, message), ...args); + } + + debug(req: Express.Request, message: string, ...args: any[]): void { + this.winston.debug(this.formatMessage(req, message), ...args); + } + + error(req: Express.Request, message: string, ...args: any[]): void { + this.winston.error(this.formatMessage(req, message), ...args); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/logging/RequestLoggerStub.spec.ts b/themes/black/server/src/lib/logging/RequestLoggerStub.spec.ts new file mode 100644 index 00000000..b0e37521 --- /dev/null +++ b/themes/black/server/src/lib/logging/RequestLoggerStub.spec.ts @@ -0,0 +1,38 @@ +import { IRequestLogger } from "./IRequestLogger"; +import Sinon = require("sinon"); +import { RequestLogger } from "./RequestLogger"; +import Winston = require("winston"); +import Express = require("express"); + +export class RequestLoggerStub implements IRequestLogger { + infoStub: Sinon.SinonStub; + debugStub: Sinon.SinonStub; + errorStub: Sinon.SinonStub; + private requestLogger: RequestLogger; + + constructor(enableLogging?: boolean) { + this.infoStub = Sinon.stub(); + this.debugStub = Sinon.stub(); + this.errorStub = Sinon.stub(); + if (enableLogging) + this.requestLogger = new RequestLogger(Winston); + } + + info(req: Express.Request, message: string, ...args: any[]): void { + if (this.requestLogger) + this.requestLogger.info(req, message, ...args); + this.infoStub(req, message, ...args); + } + + debug(req: Express.Request, message: string, ...args: any[]): void { + if (this.requestLogger) + this.requestLogger.info(req, message, ...args); + this.debugStub(req, message, ...args); + } + + error(req: Express.Request, message: string, ...args: any[]): void { + if (this.requestLogger) + this.requestLogger.info(req, message, ...args); + this.errorStub(req, message, ...args); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/notifiers/AbstractEmailNotifier.ts b/themes/black/server/src/lib/notifiers/AbstractEmailNotifier.ts new file mode 100644 index 00000000..198e4e5d --- /dev/null +++ b/themes/black/server/src/lib/notifiers/AbstractEmailNotifier.ts @@ -0,0 +1,23 @@ + +import { INotifier } from "../notifiers/INotifier"; +import { Identity } from "../../../types/Identity"; + +import Fs = require("fs"); +import Path = require("path"); +import Ejs = require("ejs"); +import BluebirdPromise = require("bluebird"); + +const email_template = Fs.readFileSync(Path.join(__dirname, "../../resources/email-template.ejs"), "UTF-8"); + +export abstract class AbstractEmailNotifier implements INotifier { + notify(to: string, subject: string, link: string): BluebirdPromise { + const d = { + url: link, + button_title: "Continue", + title: subject + }; + return this.sendEmail(to, subject, Ejs.render(email_template, d)); + } + + abstract sendEmail(to: string, subject: string, content: string): BluebirdPromise; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/notifiers/EmailNotifier.spec.ts b/themes/black/server/src/lib/notifiers/EmailNotifier.spec.ts new file mode 100644 index 00000000..8211bbc0 --- /dev/null +++ b/themes/black/server/src/lib/notifiers/EmailNotifier.spec.ts @@ -0,0 +1,54 @@ +import * as sinon from "sinon"; +import * as Assert from "assert"; +import BluebirdPromise = require("bluebird"); + +import { MailSenderStub } from "./MailSenderStub.spec"; +import EmailNotifier = require("./EmailNotifier"); + + +describe("notifiers/EmailNotifier", function () { + it("should send an email to given user", function () { + const mailSender = new MailSenderStub(); + const options = { + username: "user_gmail", + password: "pass_gmail", + sender: "admin@example.com", + service: "gmail" + }; + + mailSender.sendStub.returns(BluebirdPromise.resolve()); + const sender = new EmailNotifier.EmailNotifier(options, mailSender); + const subject = "subject"; + const url = "http://test.com"; + + return sender.notify("user@example.com", subject, url) + .then(function () { + Assert.equal(mailSender.sendStub.getCall(0).args[0].to, "user@example.com"); + Assert.equal(mailSender.sendStub.getCall(0).args[0].subject, "subject"); + return BluebirdPromise.resolve(); + }); + }); + + it("should fail while sending an email", function () { + const mailSender = new MailSenderStub(); + const options = { + username: "user_gmail", + password: "pass_gmail", + sender: "admin@example.com", + service: "gmail" + }; + + mailSender.sendStub.returns(BluebirdPromise.reject(new Error("Failed to send mail"))); + const sender = new EmailNotifier.EmailNotifier(options, mailSender); + const subject = "subject"; + const url = "http://test.com"; + + return sender.notify("user@example.com", subject, url) + .then(function () { + return BluebirdPromise.reject(new Error()); + }, function() { + Assert.equal(mailSender.sendStub.getCall(0).args[0].from, "admin@example.com"); + return BluebirdPromise.resolve(); + }); + }); +}); diff --git a/themes/black/server/src/lib/notifiers/EmailNotifier.ts b/themes/black/server/src/lib/notifiers/EmailNotifier.ts new file mode 100644 index 00000000..4df7c861 --- /dev/null +++ b/themes/black/server/src/lib/notifiers/EmailNotifier.ts @@ -0,0 +1,27 @@ + +import * as BluebirdPromise from "bluebird"; + +import { AbstractEmailNotifier } from "../notifiers/AbstractEmailNotifier"; +import { EmailNotifierConfiguration } from "../configuration/schema/NotifierConfiguration"; +import { IMailSender } from "./IMailSender"; + +export class EmailNotifier extends AbstractEmailNotifier { + private mailSender: IMailSender; + private sender: string; + + constructor(options: EmailNotifierConfiguration, mailSender: IMailSender) { + super(); + this.mailSender = mailSender; + this.sender = options.sender; + } + + sendEmail(to: string, subject: string, content: string) { + const mailOptions = { + from: this.sender, + to: to, + subject: subject, + html: content + }; + return this.mailSender.send(mailOptions); + } +} diff --git a/themes/black/server/src/lib/notifiers/FileSystemNotifier.ts b/themes/black/server/src/lib/notifiers/FileSystemNotifier.ts new file mode 100644 index 00000000..23f6242c --- /dev/null +++ b/themes/black/server/src/lib/notifiers/FileSystemNotifier.ts @@ -0,0 +1,22 @@ +import * as BluebirdPromise from "bluebird"; +import * as util from "util"; +import * as Fs from "fs"; +import { INotifier } from "./INotifier"; +import { Identity } from "../../../types/Identity"; + +import { FileSystemNotifierConfiguration } from "../configuration/schema/NotifierConfiguration"; + +export class FileSystemNotifier implements INotifier { + private filename: string; + + constructor(options: FileSystemNotifierConfiguration) { + this.filename = options.filename; + } + + notify(to: string, subject: string, link: string): BluebirdPromise { + const content = util.format("Date: %s\nEmail: %s\nSubject: %s\nLink: %s", + new Date().toString(), to, subject, link); + const writeFilePromised: any = BluebirdPromise.promisify(Fs.writeFile); + return writeFilePromised(this.filename, content); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/notifiers/IMailSender.ts b/themes/black/server/src/lib/notifiers/IMailSender.ts new file mode 100644 index 00000000..34ac464a --- /dev/null +++ b/themes/black/server/src/lib/notifiers/IMailSender.ts @@ -0,0 +1,6 @@ +import BluebirdPromise = require("bluebird"); +import Nodemailer = require("nodemailer"); + +export interface IMailSender { + send(mailOptions: Nodemailer.SendMailOptions): BluebirdPromise; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/notifiers/IMailSenderBuilder.ts b/themes/black/server/src/lib/notifiers/IMailSenderBuilder.ts new file mode 100644 index 00000000..36d4dcdf --- /dev/null +++ b/themes/black/server/src/lib/notifiers/IMailSenderBuilder.ts @@ -0,0 +1,7 @@ +import { IMailSender } from "./IMailSender"; +import { SmtpNotifierConfiguration, EmailNotifierConfiguration } from "../configuration/schema/NotifierConfiguration"; + +export interface IMailSenderBuilder { + buildEmail(options: EmailNotifierConfiguration): IMailSender; + buildSmtp(options: SmtpNotifierConfiguration): IMailSender; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/notifiers/INotifier.ts b/themes/black/server/src/lib/notifiers/INotifier.ts new file mode 100644 index 00000000..b9a6b138 --- /dev/null +++ b/themes/black/server/src/lib/notifiers/INotifier.ts @@ -0,0 +1,5 @@ +import * as BluebirdPromise from "bluebird"; + +export interface INotifier { + notify(to: string, subject: string, link: string): BluebirdPromise; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/notifiers/MailSender.ts b/themes/black/server/src/lib/notifiers/MailSender.ts new file mode 100644 index 00000000..536a88e6 --- /dev/null +++ b/themes/black/server/src/lib/notifiers/MailSender.ts @@ -0,0 +1,42 @@ +import { IMailSender } from "./IMailSender"; +import Nodemailer = require("nodemailer"); +import NodemailerDirectTransport = require("nodemailer-direct-transport"); +import NodemailerSmtpTransport = require("nodemailer-smtp-transport"); +import BluebirdPromise = require("bluebird"); + +export class MailSender implements IMailSender { + private transporter: Nodemailer.Transporter; + + constructor(options: NodemailerDirectTransport.DirectOptions | + NodemailerSmtpTransport.SmtpOptions, nodemailer: typeof Nodemailer) { + this.transporter = nodemailer.createTransport(options); + } + + verify(): BluebirdPromise { + const that = this; + return new BluebirdPromise(function (resolve, reject) { + that.transporter.verify(function (error: Error, success: any) { + if (error) { + reject(new Error("Unable to connect to SMTP server. \ + Please check the service is running and your credentials are correct.")); + return; + } + resolve(); + }); + }); + } + + send(mailOptions: Nodemailer.SendMailOptions): BluebirdPromise { + const that = this; + return new BluebirdPromise(function (resolve, reject) { + that.transporter.sendMail(mailOptions, (error: Error, + data: Nodemailer.SentMessageInfo) => { + if (error) { + reject(new Error("Error while sending email: " + error.message)); + return; + } + resolve(); + }); + }); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/notifiers/MailSenderBuilder.spec.ts b/themes/black/server/src/lib/notifiers/MailSenderBuilder.spec.ts new file mode 100644 index 00000000..41e0db42 --- /dev/null +++ b/themes/black/server/src/lib/notifiers/MailSenderBuilder.spec.ts @@ -0,0 +1,67 @@ + +import { MailSenderBuilder } from ".//MailSenderBuilder"; +import Nodemailer = require("nodemailer"); +import Sinon = require("sinon"); +import Assert = require("assert"); + +describe("notifiers/MailSenderBuilder", function() { + let createTransportStub: Sinon.SinonStub; + beforeEach(function() { + createTransportStub = Sinon.stub(Nodemailer, "createTransport"); + }); + + afterEach(function() { + createTransportStub.restore(); + }); + + it("should create a email mail sender", function() { + const mailSenderBuilder = new MailSenderBuilder(Nodemailer); + mailSenderBuilder.buildEmail({ + username: "user_gmail", + password: "pass_gmail", + sender: "admin@example.com", + service: "gmail" + }); + Assert.equal(createTransportStub.getCall(0).args[0].auth.user, "user_gmail"); + Assert.equal(createTransportStub.getCall(0).args[0].auth.pass, "pass_gmail"); + Assert.equal(createTransportStub.getCall(0).args[0].service, "gmail"); + }); + + describe("build smtp mail sender", function() { + it("should create a smtp mail sender with authenticated user", function() { + const mailSenderBuilder = new MailSenderBuilder(Nodemailer); + mailSenderBuilder.buildSmtp({ + host: "mail.example.com", + password: "password", + port: 25, + secure: true, + username: "user", + sender: "admin@example.com" + }); + Assert.deepStrictEqual(createTransportStub.getCall(0).args[0], { + host: "mail.example.com", + auth: { + pass: "password", + user: "user" + }, + port: 25, + secure: true, + }); + }); + + it("should create a smtp mail sender with anonymous user", function() { + const mailSenderBuilder = new MailSenderBuilder(Nodemailer); + mailSenderBuilder.buildSmtp({ + host: "mail.example.com", + port: 25, + secure: true, + sender: "admin@example.com" + }); + Assert.deepStrictEqual(createTransportStub.getCall(0).args[0], { + host: "mail.example.com", + port: 25, + secure: true, + }); + }); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/notifiers/MailSenderBuilder.ts b/themes/black/server/src/lib/notifiers/MailSenderBuilder.ts new file mode 100644 index 00000000..1d06be52 --- /dev/null +++ b/themes/black/server/src/lib/notifiers/MailSenderBuilder.ts @@ -0,0 +1,42 @@ +import { IMailSender } from "./IMailSender"; +import { IMailSenderBuilder } from "./IMailSenderBuilder"; +import { MailSender } from "./MailSender"; +import Nodemailer = require("nodemailer"); +import NodemailerSmtpTransport = require("nodemailer-smtp-transport"); +import { SmtpNotifierConfiguration, EmailNotifierConfiguration } from "../configuration/schema/NotifierConfiguration"; + +export class MailSenderBuilder implements IMailSenderBuilder { + private nodemailer: typeof Nodemailer; + + constructor(nodemailer: typeof Nodemailer) { + this.nodemailer = nodemailer; + } + + buildEmail(options: EmailNotifierConfiguration): IMailSender { + const emailOptions = { + service: options.service, + auth: { + user: options.username, + pass: options.password + } + }; + return new MailSender(emailOptions, this.nodemailer); + } + + buildSmtp(options: SmtpNotifierConfiguration): IMailSender { + const smtpOptions: NodemailerSmtpTransport.SmtpOptions = { + host: options.host, + port: options.port, + secure: options.secure, // upgrade later with STARTTLS + }; + + if (options.username && options.password) { + smtpOptions.auth = { + user: options.username, + pass: options.password + }; + } + + return new MailSender(smtpOptions, this.nodemailer); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/notifiers/MailSenderBuilderStub.spec.ts b/themes/black/server/src/lib/notifiers/MailSenderBuilderStub.spec.ts new file mode 100644 index 00000000..5b76f6e5 --- /dev/null +++ b/themes/black/server/src/lib/notifiers/MailSenderBuilderStub.spec.ts @@ -0,0 +1,25 @@ +import { IMailSenderBuilder } from "../../../src/lib/notifiers/IMailSenderBuilder"; +import BluebirdPromise = require("bluebird"); +import Nodemailer = require("nodemailer"); +import Sinon = require("sinon"); +import { IMailSender } from "../../../src/lib/notifiers/IMailSender"; +import { SmtpNotifierConfiguration, EmailNotifierConfiguration } from "../../../src/lib/configuration/schema/NotifierConfiguration"; + +export class MailSenderBuilderStub implements IMailSenderBuilder { + buildEmailStub: Sinon.SinonStub; + buildSmtpStub: Sinon.SinonStub; + + constructor() { + this.buildEmailStub = Sinon.stub(); + this.buildSmtpStub = Sinon.stub(); + } + + buildEmail(options: EmailNotifierConfiguration): IMailSender { + return this.buildEmailStub(options); + } + + buildSmtp(options: SmtpNotifierConfiguration): IMailSender { + return this.buildSmtpStub(options); + } + +} \ No newline at end of file diff --git a/themes/black/server/src/lib/notifiers/MailSenderStub.spec.ts b/themes/black/server/src/lib/notifiers/MailSenderStub.spec.ts new file mode 100644 index 00000000..d57c458f --- /dev/null +++ b/themes/black/server/src/lib/notifiers/MailSenderStub.spec.ts @@ -0,0 +1,16 @@ +import { IMailSender } from "../../../src/lib/notifiers/IMailSender"; +import BluebirdPromise = require("bluebird"); +import Nodemailer = require("nodemailer"); +import Sinon = require("sinon"); + +export class MailSenderStub implements IMailSender { + sendStub: Sinon.SinonStub; + + constructor() { + this.sendStub = Sinon.stub(); + } + + send(mailOptions: Nodemailer.SendMailOptions): BluebirdPromise { + return this.sendStub(mailOptions); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/notifiers/NotifierFactory.spec.ts b/themes/black/server/src/lib/notifiers/NotifierFactory.spec.ts new file mode 100644 index 00000000..f15e7667 --- /dev/null +++ b/themes/black/server/src/lib/notifiers/NotifierFactory.spec.ts @@ -0,0 +1,42 @@ + +import * as sinon from "sinon"; +import * as BluebirdPromise from "bluebird"; +import * as assert from "assert"; + +import { NotifierFactory } from "./NotifierFactory"; +import { EmailNotifier } from "./EmailNotifier"; +import { SmtpNotifier } from "./SmtpNotifier"; +import { MailSenderBuilderStub } from "./MailSenderBuilderStub.spec"; + + +describe("notifiers/NotifierFactory", function () { + let mailSenderBuilderStub: MailSenderBuilderStub; + it("should build a Email Notifier", function () { + const options = { + email: { + username: "abc", + password: "password", + sender: "admin@example.com", + service: "gmail" + } + }; + mailSenderBuilderStub = new MailSenderBuilderStub(); + assert(NotifierFactory.build(options, mailSenderBuilderStub) instanceof EmailNotifier); + }); + + it("should build a SMTP Notifier", function () { + const options = { + smtp: { + username: "user", + password: "pass", + secure: true, + host: "localhost", + port: 25, + sender: "admin@example.com" + } + }; + + mailSenderBuilderStub = new MailSenderBuilderStub(); + assert(NotifierFactory.build(options, mailSenderBuilderStub) instanceof SmtpNotifier); + }); +}); diff --git a/themes/black/server/src/lib/notifiers/NotifierFactory.ts b/themes/black/server/src/lib/notifiers/NotifierFactory.ts new file mode 100644 index 00000000..a89155fe --- /dev/null +++ b/themes/black/server/src/lib/notifiers/NotifierFactory.ts @@ -0,0 +1,33 @@ + +import { NotifierConfiguration } from "../configuration/schema/NotifierConfiguration"; +import Nodemailer = require("nodemailer"); +import { INotifier } from "./INotifier"; + +import { FileSystemNotifier } from "./FileSystemNotifier"; +import { EmailNotifier } from "./EmailNotifier"; +import { SmtpNotifier } from "./SmtpNotifier"; +import { IMailSender } from "./IMailSender"; +import { IMailSenderBuilder } from "./IMailSenderBuilder"; + +export class NotifierFactory { + static build(options: NotifierConfiguration, mailSenderBuilder: IMailSenderBuilder): INotifier { + if ("email" in options) { + const mailSender = mailSenderBuilder.buildEmail(options.email); + return new EmailNotifier(options.email, mailSender); + } + else if ("smtp" in options) { + const mailSender = mailSenderBuilder.buildSmtp(options.smtp); + return new SmtpNotifier(options.smtp, mailSender); + } + else if ("filesystem" in options) { + return new FileSystemNotifier(options.filesystem); + } + else { + throw new Error("No available notifier option detected."); + } + } +} + + + + diff --git a/themes/black/server/src/lib/notifiers/NotifierStub.spec.ts b/themes/black/server/src/lib/notifiers/NotifierStub.spec.ts new file mode 100644 index 00000000..f99231b5 --- /dev/null +++ b/themes/black/server/src/lib/notifiers/NotifierStub.spec.ts @@ -0,0 +1,16 @@ +import Sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); + +import { INotifier } from "./INotifier"; + +export class NotifierStub implements INotifier { + notifyStub: Sinon.SinonStub; + + constructor() { + this.notifyStub = Sinon.stub(); + } + + notify(to: string, subject: string, link: string): BluebirdPromise { + return this.notifyStub(to, subject, link); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/notifiers/SmtpNotifier.ts b/themes/black/server/src/lib/notifiers/SmtpNotifier.ts new file mode 100644 index 00000000..f93a6d4a --- /dev/null +++ b/themes/black/server/src/lib/notifiers/SmtpNotifier.ts @@ -0,0 +1,30 @@ + + +import * as BluebirdPromise from "bluebird"; + +import { IMailSender } from "./IMailSender"; +import { AbstractEmailNotifier } from "../notifiers/AbstractEmailNotifier"; +import { SmtpNotifierConfiguration } from "../configuration/schema/NotifierConfiguration"; + +export class SmtpNotifier extends AbstractEmailNotifier { + private mailSender: IMailSender; + private sender: string; + + constructor(options: SmtpNotifierConfiguration, + mailSender: IMailSender) { + super(); + this.mailSender = mailSender; + this.sender = options.sender; + } + + sendEmail(to: string, subject: string, content: string) { + const mailOptions = { + from: this.sender, + to: to, + subject: subject, + html: content + }; + const that = this; + return this.mailSender.send(mailOptions); + } +} diff --git a/themes/black/server/src/lib/regulation/IRegulator.ts b/themes/black/server/src/lib/regulation/IRegulator.ts new file mode 100644 index 00000000..c49425b2 --- /dev/null +++ b/themes/black/server/src/lib/regulation/IRegulator.ts @@ -0,0 +1,6 @@ +import BluebirdPromise = require("bluebird"); + +export interface IRegulator { + mark(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise; + regulate(userId: string): BluebirdPromise; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/regulation/Regulator.spec.ts b/themes/black/server/src/lib/regulation/Regulator.spec.ts new file mode 100644 index 00000000..f9c6e608 --- /dev/null +++ b/themes/black/server/src/lib/regulation/Regulator.spec.ts @@ -0,0 +1,186 @@ + +import Sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); +import Assert = require("assert"); + +import { Regulator } from "./Regulator"; +import MockDate = require("mockdate"); +import exceptions = require("../Exceptions"); +import { UserDataStoreStub } from "../storage/UserDataStoreStub.spec"; + +describe("regulation/Regulator", function () { + const USER1 = "USER1"; + const USER2 = "USER2"; + let userDataStoreStub: UserDataStoreStub; + + beforeEach(function () { + userDataStoreStub = new UserDataStoreStub(); + const dataStore: { [userId: string]: { userId: string, date: Date, isAuthenticationSuccessful: boolean }[] } = { + [USER1]: [], + [USER2]: [] + }; + + userDataStoreStub.saveAuthenticationTraceStub.callsFake(function (userId, isAuthenticationSuccessful) { + dataStore[userId].unshift({ + userId: userId, + date: new Date(), + isAuthenticationSuccessful: isAuthenticationSuccessful, + }); + return BluebirdPromise.resolve(); + }); + + userDataStoreStub.retrieveLatestAuthenticationTracesStub.callsFake(function (userId, count) { + const ret = (dataStore[userId].length <= count) ? dataStore[userId] : dataStore[userId].slice(0, 3); + return BluebirdPromise.resolve(ret); + }); + }); + + afterEach(function () { + MockDate.reset(); + }); + + function markAuthenticationAt(regulator: Regulator, user: string, time: string, success: boolean) { + MockDate.set(time); + return regulator.mark(user, success); + } + + it("should mark 2 authentication and regulate (accept)", function () { + const regulator = new Regulator(userDataStoreStub, 3, 10, 10); + + return regulator.mark(USER1, false) + .then(function () { + return regulator.mark(USER1, true); + }) + .then(function () { + return regulator.regulate(USER1); + }); + }); + + it("should mark 3 authentications and regulate (reject)", function () { + const regulator = new Regulator(userDataStoreStub, 3, 10, 10); + + return regulator.mark(USER1, false) + .then(function () { + return regulator.mark(USER1, false); + }) + .then(function () { + return regulator.mark(USER1, false); + }) + .then(function () { + return regulator.regulate(USER1); + }) + .then(function () { return BluebirdPromise.reject(new Error("should not be here!")); }) + .catch(exceptions.AuthenticationRegulationError, function () { + return BluebirdPromise.resolve(); + }); + }); + + it("should mark 1 failed, 1 successful and 1 failed authentications within minimum time and regulate (accept)", function () { + const regulator = new Regulator(userDataStoreStub, 3, 60, 30); + + return markAuthenticationAt(regulator, USER1, "1/2/2000 00:00:00", false) + .then(function () { + return markAuthenticationAt(regulator, USER1, "1/2/2000 00:00:10", true); + }) + .then(function () { + return markAuthenticationAt(regulator, USER1, "1/2/2000 00:00:20", false); + }) + .then(function () { + return regulator.regulate(USER1); + }) + .then(function () { + return markAuthenticationAt(regulator, USER1, "1/2/2000 00:00:30", false); + }) + .then(function () { + return regulator.regulate(USER1); + }) + .then(function () { + return markAuthenticationAt(regulator, USER1, "1/2/2000 00:00:39", false); + }) + .then(function () { + return regulator.regulate(USER1); + }) + .then(function () { + return BluebirdPromise.reject(new Error("should not be here!")); + }, + function () { + return BluebirdPromise.resolve(); + }); + }); + + it("should regulate user if number of failures is greater than 3 in allowed time lapse", function () { + function markAuthentications(regulator: Regulator, user: string) { + return markAuthenticationAt(regulator, user, "1/2/2000 00:00:00", false) + .then(function () { + return markAuthenticationAt(regulator, user, "1/2/2000 00:00:45", false); + }) + .then(function () { + return markAuthenticationAt(regulator, user, "1/2/2000 00:01:05", false); + }) + .then(function () { + return regulator.regulate(user); + }); + } + + const regulator1 = new Regulator(userDataStoreStub, 3, 60, 60); + const regulator2 = new Regulator(userDataStoreStub, 3, 2 * 60, 60); + + const p1 = markAuthentications(regulator1, USER1); + const p2 = markAuthentications(regulator2, USER2); + + return BluebirdPromise.join(p1, p2) + .then(function () { + return BluebirdPromise.reject(new Error("should not be here...")); + }, function () { + Assert(p1.isFulfilled()); + Assert(p2.isRejected()); + }); + }); + + it("should user wait after regulation to authenticate again", function () { + function markAuthentications(regulator: Regulator, user: string) { + return markAuthenticationAt(regulator, user, "1/2/2000 00:00:00", false) + .then(function () { + return markAuthenticationAt(regulator, user, "1/2/2000 00:00:10", false); + }) + .then(function () { + return markAuthenticationAt(regulator, user, "1/2/2000 00:00:15", false); + }) + .then(function () { + return markAuthenticationAt(regulator, user, "1/2/2000 00:00:25", false); + }) + .then(function () { + MockDate.set("1/2/2000 00:00:54"); + return regulator.regulate(user); + }) + .then(function () { + return BluebirdPromise.reject(new Error("should fail at this time")); + }, function () { + MockDate.set("1/2/2000 00:00:56"); + return regulator.regulate(user); + }); + } + + const regulator = new Regulator(userDataStoreStub, 4, 30, 30); + return markAuthentications(regulator, USER1); + }); + + it("should disable regulation when max_retries is set to 0", function () { + const maxRetries = 0; + const regulator = new Regulator(userDataStoreStub, maxRetries, 60, 30); + return markAuthenticationAt(regulator, USER1, "1/2/2000 00:00:00", false) + .then(function () { + return markAuthenticationAt(regulator, USER1, "1/2/2000 00:00:10", false); + }) + .then(function () { + return markAuthenticationAt(regulator, USER1, "1/2/2000 00:00:15", false); + }) + .then(function () { + return markAuthenticationAt(regulator, USER1, "1/2/2000 00:00:25", false); + }) + .then(function () { + MockDate.set("1/2/2000 00:00:26"); + return regulator.regulate(USER1); + }); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/regulation/Regulator.ts b/themes/black/server/src/lib/regulation/Regulator.ts new file mode 100644 index 00000000..1037a6a1 --- /dev/null +++ b/themes/black/server/src/lib/regulation/Regulator.ts @@ -0,0 +1,55 @@ + +import * as BluebirdPromise from "bluebird"; +import exceptions = require("../Exceptions"); +import { IUserDataStore } from "../storage/IUserDataStore"; +import { AuthenticationTraceDocument } from "../storage/AuthenticationTraceDocument"; +import { IRegulator } from "./IRegulator"; + +export class Regulator implements IRegulator { + private userDataStore: IUserDataStore; + private banTime: number; + private findTime: number; + private maxRetries: number; + + constructor(userDataStore: any, maxRetries: number, findTime: number, banTime: number) { + this.userDataStore = userDataStore; + this.banTime = banTime; + this.findTime = findTime; + this.maxRetries = maxRetries; + } + + // Mark authentication + mark(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise { + return this.userDataStore.saveAuthenticationTrace(userId, isAuthenticationSuccessful); + } + + regulate(userId: string): BluebirdPromise { + const that = this; + + if (that.maxRetries <= 0) return BluebirdPromise.resolve(); + + return this.userDataStore.retrieveLatestAuthenticationTraces(userId, that.maxRetries) + .then((docs: AuthenticationTraceDocument[]) => { + // less than the max authorized number of authentication in time range, thus authorizing access + if (docs.length < that.maxRetries) return BluebirdPromise.resolve(); + + const numberOfFailedAuth = docs + .map(function (d: AuthenticationTraceDocument) { return d.isAuthenticationSuccessful == false ? 1 : 0; }) + .reduce(function (acc, v) { return acc + v; }, 0); + + if (numberOfFailedAuth < this.maxRetries) return BluebirdPromise.resolve(); + + const newestDocument = docs[0]; + const oldestDocument = docs[that.maxRetries - 1]; + + const authenticationsTimeRangeInSeconds = (newestDocument.date.getTime() - oldestDocument.date.getTime()) / 1000; + const tooManyAuthInTimelapse = (authenticationsTimeRangeInSeconds < this.findTime); + const stillInBannedTimeRange = (new Date(new Date().getTime() - this.banTime * 1000) < newestDocument.date); + + if (tooManyAuthInTimelapse && stillInBannedTimeRange) + throw new exceptions.AuthenticationRegulationError("Max number of authentication. Please retry in few minutes."); + + return BluebirdPromise.resolve(); + }); + } +} diff --git a/themes/black/server/src/lib/regulation/RegulatorStub.spec.ts b/themes/black/server/src/lib/regulation/RegulatorStub.spec.ts new file mode 100644 index 00000000..ca8a00fb --- /dev/null +++ b/themes/black/server/src/lib/regulation/RegulatorStub.spec.ts @@ -0,0 +1,22 @@ +import Bluebird = require("bluebird"); +import Sinon = require("sinon"); +import { IRegulator } from "./IRegulator"; + + +export class RegulatorStub implements IRegulator { + markStub: Sinon.SinonStub; + regulateStub: Sinon.SinonStub; + + constructor() { + this.markStub = Sinon.stub(); + this.regulateStub = Sinon.stub(); + } + + mark(userId: string, isAuthenticationSuccessful: boolean): Bluebird { + return this.markStub(userId, isAuthenticationSuccessful); + } + + regulate(userId: string): Bluebird { + return this.regulateStub(userId); + } +} diff --git a/themes/black/server/src/lib/routes/error/401/get.spec.ts b/themes/black/server/src/lib/routes/error/401/get.spec.ts new file mode 100644 index 00000000..9fdac9c3 --- /dev/null +++ b/themes/black/server/src/lib/routes/error/401/get.spec.ts @@ -0,0 +1,61 @@ +import Sinon = require("sinon"); +import Express = require("express"); +import Assert = require("assert"); +import Get401 from "./get"; +import { ServerVariables } from "../../../ServerVariables"; +import { ServerVariablesMockBuilder, ServerVariablesMock } + from "../../../ServerVariablesMockBuilder.spec"; + +describe("routes/error/401/get", function () { + let vars: ServerVariables; + let mocks: ServerVariablesMock; + let req: any; + let res: any; + let renderSpy: Sinon.SinonSpy; + + beforeEach(function () { + const s = ServerVariablesMockBuilder.build(); + vars = s.variables; + mocks = s.mocks; + + renderSpy = Sinon.spy(); + req = { + headers: {} + }; + res = { + render: renderSpy + }; + }); + + it("should set redirection url to the default redirection url", function () { + vars.config.default_redirection_url = "http://default-redirection"; + return Get401(vars)(req, res as any) + .then(function () { + Assert(renderSpy.calledOnce); + Assert(renderSpy.calledWithExactly("errors/401", { + redirection_url: "http://default-redirection" + })); + }); + }); + + it("should set redirection url to the referer", function () { + req.headers["referer"] = "http://redirection"; + return Get401(vars)(req, res as any) + .then(function () { + Assert(renderSpy.calledOnce); + Assert(renderSpy.calledWithExactly("errors/401", { + redirection_url: "http://redirection" + })); + }); + }); + + it("should render without redirecting the user", function () { + return Get401(vars)(req, res as any) + .then(function () { + Assert(renderSpy.calledOnce); + Assert(renderSpy.calledWithExactly("errors/401", { + redirection_url: undefined + })); + }); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/error/401/get.ts b/themes/black/server/src/lib/routes/error/401/get.ts new file mode 100644 index 00000000..ca4a3963 --- /dev/null +++ b/themes/black/server/src/lib/routes/error/401/get.ts @@ -0,0 +1,15 @@ + +import BluebirdPromise = require("bluebird"); +import express = require("express"); +import redirector from "../redirector"; +import { ServerVariables } from "../../../ServerVariables"; + +export default function (vars: ServerVariables) { + return function (req: express.Request, res: express.Response): BluebirdPromise { + const redirectionUrl = redirector(req, vars); + res.render("errors/401", { + redirection_url: redirectionUrl + }); + return BluebirdPromise.resolve(); + }; +} diff --git a/themes/black/server/src/lib/routes/error/403/get.spec.ts b/themes/black/server/src/lib/routes/error/403/get.spec.ts new file mode 100644 index 00000000..22eb8485 --- /dev/null +++ b/themes/black/server/src/lib/routes/error/403/get.spec.ts @@ -0,0 +1,61 @@ +import Sinon = require("sinon"); +import Express = require("express"); +import Assert = require("assert"); +import Get403 from "./get"; +import { ServerVariables } from "../../../ServerVariables"; +import { ServerVariablesMockBuilder, ServerVariablesMock } + from "../../../ServerVariablesMockBuilder.spec"; + +describe("routes/error/403/get", function () { + let vars: ServerVariables; + let mocks: ServerVariablesMock; + let req: any; + let res: any; + let renderSpy: Sinon.SinonSpy; + + beforeEach(function () { + const s = ServerVariablesMockBuilder.build(); + vars = s.variables; + mocks = s.mocks; + + renderSpy = Sinon.spy(); + req = { + headers: {} + }; + res = { + render: renderSpy + }; + }); + + it("should set redirection url to the default redirection url", function () { + vars.config.default_redirection_url = "http://default-redirection"; + return Get403(vars)(req, res as any) + .then(function () { + Assert(renderSpy.calledOnce); + Assert(renderSpy.calledWithExactly("errors/403", { + redirection_url: "http://default-redirection" + })); + }); + }); + + it("should set redirection url to the referer", function () { + req.headers["referer"] = "http://redirection"; + return Get403(vars)(req, res as any) + .then(function () { + Assert(renderSpy.calledOnce); + Assert(renderSpy.calledWithExactly("errors/403", { + redirection_url: "http://redirection" + })); + }); + }); + + it("should render without redirecting the user", function () { + return Get403(vars)(req, res as any) + .then(function () { + Assert(renderSpy.calledOnce); + Assert(renderSpy.calledWithExactly("errors/403", { + redirection_url: undefined + })); + }); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/error/403/get.ts b/themes/black/server/src/lib/routes/error/403/get.ts new file mode 100644 index 00000000..3ab0319e --- /dev/null +++ b/themes/black/server/src/lib/routes/error/403/get.ts @@ -0,0 +1,15 @@ + +import BluebirdPromise = require("bluebird"); +import express = require("express"); +import redirector from "../redirector"; +import { ServerVariables } from "../../../ServerVariables"; + +export default function (vars: ServerVariables) { + return function (req: express.Request, res: express.Response): BluebirdPromise { + const redirectionUrl = redirector(req, vars); + res.render("errors/403", { + redirection_url: redirectionUrl + }); + return BluebirdPromise.resolve(); + }; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/error/404/get.spec.ts b/themes/black/server/src/lib/routes/error/404/get.spec.ts new file mode 100644 index 00000000..73e4e6ce --- /dev/null +++ b/themes/black/server/src/lib/routes/error/404/get.spec.ts @@ -0,0 +1,19 @@ +import Sinon = require("sinon"); +import Express = require("express"); +import Assert = require("assert"); +import Get404 from "./get"; + +describe("routes/error/404/get", function () { + it("should render the page", function () { + const req = {} as Express.Request; + const res = { + render: Sinon.stub() + }; + + return Get404(req, res as any) + .then(function () { + Assert(res.render.calledOnce); + Assert(res.render.calledWith("errors/404")); + }); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/error/404/get.ts b/themes/black/server/src/lib/routes/error/404/get.ts new file mode 100644 index 00000000..6693b6fc --- /dev/null +++ b/themes/black/server/src/lib/routes/error/404/get.ts @@ -0,0 +1,8 @@ + +import BluebirdPromise = require("bluebird"); +import express = require("express"); + +export default function (req: express.Request, res: express.Response): BluebirdPromise { + res.render("errors/404"); + return BluebirdPromise.resolve(); +} \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/error/redirector.ts b/themes/black/server/src/lib/routes/error/redirector.ts new file mode 100644 index 00000000..b1a3ccc1 --- /dev/null +++ b/themes/black/server/src/lib/routes/error/redirector.ts @@ -0,0 +1,13 @@ +import Express = require("express"); +import { ServerVariables } from "../../ServerVariables"; + +export default function (req: Express.Request, vars: ServerVariables): string { + let redirectionUrl: string; + + if (req.headers && req.headers["referer"]) + redirectionUrl = "" + req.headers["referer"]; + else if (vars.config.default_redirection_url) + redirectionUrl = vars.config.default_redirection_url; + + return redirectionUrl; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/firstfactor/get.ts b/themes/black/server/src/lib/routes/firstfactor/get.ts new file mode 100644 index 00000000..d94f656c --- /dev/null +++ b/themes/black/server/src/lib/routes/firstfactor/get.ts @@ -0,0 +1,72 @@ + +import express = require("express"); +import Endpoints = require("../../../../../shared/api"); +import BluebirdPromise = require("bluebird"); +import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler"; +import Constants = require("../../../../../shared/constants"); +import Util = require("util"); +import { ServerVariables } from "../../ServerVariables"; +import { SafeRedirector } from "../../utils/SafeRedirection"; +import { Level } from "../../authentication/Level"; + +function getRedirectParam( + req: express.Request) { + return req.query[Constants.REDIRECT_QUERY_PARAM] != "undefined" + ? req.query[Constants.REDIRECT_QUERY_PARAM] + : undefined; +} + +function redirectToSecondFactorPage( + req: express.Request, + res: express.Response) { + + const redirectUrl = getRedirectParam(req); + if (!redirectUrl) + res.redirect(Endpoints.SECOND_FACTOR_GET); + else + res.redirect( + Util.format("%s?%s=%s", + Endpoints.SECOND_FACTOR_GET, + Constants.REDIRECT_QUERY_PARAM, + redirectUrl)); +} + +function redirectToService( + req: express.Request, + res: express.Response, + redirector: SafeRedirector) { + const redirectUrl = getRedirectParam(req); + if (!redirectUrl) { + res.redirect(Endpoints.LOGGED_IN); + } else { + redirector.redirectOrElse(res, redirectUrl, Endpoints.LOGGED_IN); + } +} + +function renderFirstFactor( + res: express.Response) { + + res.render("firstfactor", { + first_factor_post_endpoint: Endpoints.FIRST_FACTOR_POST, + reset_password_request_endpoint: Endpoints.RESET_PASSWORD_REQUEST_GET + }); +} + +export default function ( + vars: ServerVariables) { + + const redirector = new SafeRedirector(vars.config.session.domain); + return function (req: express.Request, res: express.Response): BluebirdPromise { + return new BluebirdPromise(function (resolve, reject) { + const authSession = AuthenticationSessionHandler.get(req, vars.logger); + if (authSession.authentication_level == Level.ONE_FACTOR) { + redirectToSecondFactorPage(req, res); + } else if (authSession.authentication_level == Level.TWO_FACTOR) { + redirectToService(req, res, redirector); + } else { + renderFirstFactor(res); + } + resolve(); + }); + }; +} diff --git a/themes/black/server/src/lib/routes/firstfactor/post.spec.ts b/themes/black/server/src/lib/routes/firstfactor/post.spec.ts new file mode 100644 index 00000000..e1d078cd --- /dev/null +++ b/themes/black/server/src/lib/routes/firstfactor/post.spec.ts @@ -0,0 +1,136 @@ + +import Sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); +import Assert = require("assert"); +import FirstFactorPost = require("./post"); +import exceptions = require("../../Exceptions"); +import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler"; +import { AuthenticationSession } from "../../../../types/AuthenticationSession"; +import Endpoints = require("../../../../../shared/api"); +import AuthenticationRegulatorMock = require("../../regulation/RegulatorStub.spec"); +import ExpressMock = require("../../stubs/express.spec"); +import { ServerVariablesMock, ServerVariablesMockBuilder } from "../../ServerVariablesMockBuilder.spec"; +import { ServerVariables } from "../../ServerVariables"; + +describe("routes/firstfactor/post", function () { + let req: ExpressMock.RequestMock; + let res: ExpressMock.ResponseMock; + let emails: string[]; + let groups: string[]; + let vars: ServerVariables; + let mocks: ServerVariablesMock; + let authSession: AuthenticationSession; + + beforeEach(function () { + emails = ["test_ok@example.com"]; + groups = ["group1", "group2" ]; + const s = ServerVariablesMockBuilder.build(); + mocks = s.mocks; + vars = s.variables; + + mocks.authorizer.authorizationMock.returns(true); + mocks.regulator.regulateStub.returns(BluebirdPromise.resolve()); + mocks.regulator.markStub.returns(BluebirdPromise.resolve()); + + req = { + originalUrl: "/api/firstfactor", + body: { + username: "username", + password: "password" + }, + query: { + redirect: "http://redirect.url" + }, + session: { + cookie: {} + }, + headers: { + host: "home.example.com" + } + }; + + res = ExpressMock.ResponseMock(); + authSession = AuthenticationSessionHandler.get(req as any, vars.logger); + }); + + it("should reply with 204 if success", function () { + mocks.usersDatabase.checkUserPasswordStub.withArgs("username", "password") + .returns(BluebirdPromise.resolve({ + emails: emails, + groups: groups + })); + return FirstFactorPost.default(vars)(req as any, res as any) + .then(function () { + Assert.equal("username", authSession.userid); + Assert(res.send.calledOnce); + }); + }); + + describe("keep me logged in", () => { + beforeEach(() => { + mocks.usersDatabase.checkUserPasswordStub.withArgs("username", "password") + .returns(BluebirdPromise.resolve({ + emails: emails, + groups: groups + })); + req.body.keepMeLoggedIn = "true"; + return FirstFactorPost.default(vars)(req as any, res as any); + }); + + it("should set keep_me_logged_in session variable to true", function () { + Assert.equal(authSession.keep_me_logged_in, true); + }); + + it("should set cookie maxAge to one year", function () { + Assert.equal(req.session.cookie.maxAge, 365 * 24 * 60 * 60 * 1000); + }); + }); + + it("should retrieve email from LDAP", function () { + mocks.usersDatabase.checkUserPasswordStub.withArgs("username", "password") + .returns(BluebirdPromise.resolve([{ mail: ["test@example.com"] }])); + return FirstFactorPost.default(vars)(req as any, res as any); + }); + + it("should set first email address as user session variable", function () { + const emails = ["test_ok@example.com"]; + mocks.usersDatabase.checkUserPasswordStub.withArgs("username", "password") + .returns(BluebirdPromise.resolve({ + emails: emails, + groups: groups + })); + + return FirstFactorPost.default(vars)(req as any, res as any) + .then(function () { + Assert.equal("test_ok@example.com", authSession.email); + }); + }); + + it("should return error message when LDAP authenticator throws", function () { + mocks.usersDatabase.checkUserPasswordStub.withArgs("username", "password") + .returns(BluebirdPromise.reject(new exceptions.LdapBindError("Bad credentials"))); + + return FirstFactorPost.default(vars)(req as any, res as any) + .then(function () { + Assert.equal(res.status.getCall(0).args[0], 200); + Assert.equal(mocks.regulator.markStub.getCall(0).args[0], "username"); + Assert.deepEqual(res.send.getCall(0).args[0], { + error: "Operation failed." + }); + }); + }); + + it("should return error message when regulator rejects authentication", function () { + const err = new exceptions.AuthenticationRegulationError("Authentication regulation..."); + mocks.regulator.regulateStub.returns(BluebirdPromise.reject(err)); + return FirstFactorPost.default(vars)(req as any, res as any) + .then(function () { + Assert.equal(res.status.getCall(0).args[0], 200); + Assert.deepEqual(res.send.getCall(0).args[0], { + error: "Operation failed." + }); + }); + }); +}); + + diff --git a/themes/black/server/src/lib/routes/firstfactor/post.ts b/themes/black/server/src/lib/routes/firstfactor/post.ts new file mode 100644 index 00000000..565681d6 --- /dev/null +++ b/themes/black/server/src/lib/routes/firstfactor/post.ts @@ -0,0 +1,101 @@ + +import Exceptions = require("../../Exceptions"); +import BluebirdPromise = require("bluebird"); +import express = require("express"); +import Endpoint = require("../../../../../shared/api"); +import ErrorReplies = require("../../ErrorReplies"); +import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler"; +import Constants = require("../../../../../shared/constants"); +import UserMessages = require("../../../../../shared/UserMessages"); +import { ServerVariables } from "../../ServerVariables"; +import { AuthenticationSession } from "../../../../types/AuthenticationSession"; +import { GroupsAndEmails } from "../../authentication/backends/GroupsAndEmails"; +import { Level as AuthenticationLevel } from "../../authentication/Level"; +import { Level as AuthorizationLevel } from "../../authorization/Level"; +import { URLDecomposer } from "../../utils/URLDecomposer"; + +export default function (vars: ServerVariables) { + return function (req: express.Request, res: express.Response) + : BluebirdPromise { + const username: string = req.body.username; + const password: string = req.body.password; + const keepMeLoggedIn: boolean = req.body.keepMeLoggedIn && + req.body.keepMeLoggedIn === "true"; + let authSession: AuthenticationSession; + + if (keepMeLoggedIn) { + // Stay connected for 1 year. + vars.logger.debug(req, "User requested to stay logged in for one year."); + req.session.cookie.maxAge = 365 * 24 * 60 * 60 * 1000; + } + + return BluebirdPromise.resolve() + .then(function () { + if (!username || !password) { + return BluebirdPromise.reject(new Error("No username or password.")); + } + vars.logger.info(req, "Starting authentication of user \"%s\"", username); + authSession = AuthenticationSessionHandler.get(req, vars.logger); + return vars.regulator.regulate(username); + }) + .then(function () { + vars.logger.info(req, "No regulation applied."); + return vars.usersDatabase.checkUserPassword(username, password); + }) + .then(function (groupsAndEmails: GroupsAndEmails) { + vars.logger.info(req, + "LDAP binding successful. Retrieved information about user are %s", + JSON.stringify(groupsAndEmails)); + authSession.userid = username; + authSession.keep_me_logged_in = keepMeLoggedIn; + authSession.authentication_level = AuthenticationLevel.ONE_FACTOR; + const redirectUrl: string = req.query[Constants.REDIRECT_QUERY_PARAM] !== "undefined" + // Fuck, don't know why it is a string! + ? req.query[Constants.REDIRECT_QUERY_PARAM] + : ""; + + const emails: string[] = groupsAndEmails.emails; + const groups: string[] = groupsAndEmails.groups; + const decomposition = URLDecomposer.fromUrl(redirectUrl); + const authorizationLevel = (decomposition) + ? vars.authorizer.authorization( + {domain: decomposition.domain, resource: decomposition.path}, + {user: username, groups: groups}) + : AuthorizationLevel.TWO_FACTOR; + + if (emails.length > 0) + authSession.email = emails[0]; + authSession.groups = groups; + + vars.logger.debug(req, "Mark successful authentication to regulator."); + vars.regulator.mark(username, true); + + if (authorizationLevel <= AuthorizationLevel.ONE_FACTOR) { + let newRedirectionUrl: string = redirectUrl; + if (!newRedirectionUrl) + newRedirectionUrl = Endpoint.LOGGED_IN; + res.send({ + redirect: newRedirectionUrl + }); + vars.logger.debug(req, "Redirect to '%s'", redirectUrl); + } + else { + let newRedirectUrl = Endpoint.SECOND_FACTOR_GET; + if (redirectUrl) { + newRedirectUrl += "?" + Constants.REDIRECT_QUERY_PARAM + "=" + + redirectUrl; + } + vars.logger.debug(req, "Redirect to '%s'", newRedirectUrl); + res.send({ + redirect: newRedirectUrl + }); + } + return BluebirdPromise.resolve(); + }) + .catch(Exceptions.LdapBindError, function (err: Error) { + vars.regulator.mark(username, false); + return ErrorReplies.replyWithError200(req, res, vars.logger, UserMessages.OPERATION_FAILED)(err); + }) + .catch(ErrorReplies.replyWithError200(req, res, vars.logger, UserMessages.OPERATION_FAILED)); + }; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/loggedin/get.ts b/themes/black/server/src/lib/routes/loggedin/get.ts new file mode 100644 index 00000000..283a041b --- /dev/null +++ b/themes/black/server/src/lib/routes/loggedin/get.ts @@ -0,0 +1,23 @@ +import Express = require("express"); +import Endpoints = require("../../../../../shared/api"); +import BluebirdPromise = require("bluebird"); +import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler"; +import { ServerVariables } from "../../ServerVariables"; +import ErrorReplies = require("../../ErrorReplies"); + +export default function (vars: ServerVariables) { + function handler(req: Express.Request, res: Express.Response): BluebirdPromise { + return new BluebirdPromise(function (resolve, reject) { + const authSession = AuthenticationSessionHandler.get(req, vars.logger); + res.render("already-logged-in", { + logout_endpoint: Endpoints.LOGOUT_GET, + username: authSession.userid, + redirection_url: vars.config.default_redirection_url + }); + resolve(); + }) + .catch(ErrorReplies.replyWithError401(req, res, vars.logger)); + } + + return handler; +} diff --git a/themes/black/server/src/lib/routes/logout/get.ts b/themes/black/server/src/lib/routes/logout/get.ts new file mode 100644 index 00000000..4d511214 --- /dev/null +++ b/themes/black/server/src/lib/routes/logout/get.ts @@ -0,0 +1,20 @@ + +import express = require("express"); +import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler"; +import Constants = require("../../../../../shared/constants"); +import { ServerVariables } from "../../ServerVariables"; + +function getRedirectParam(req: express.Request) { + return req.query[Constants.REDIRECT_QUERY_PARAM] != "undefined" + ? req.query[Constants.REDIRECT_QUERY_PARAM] + : undefined; +} + +export default function (vars: ServerVariables) { + return function(req: express.Request, res: express.Response) { + const redirect_param = getRedirectParam(req); + const redirect_url = redirect_param || "/"; + AuthenticationSessionHandler.reset(req); + res.redirect(redirect_url); + }; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/password-reset/constants.ts b/themes/black/server/src/lib/routes/password-reset/constants.ts new file mode 100644 index 00000000..5c639e92 --- /dev/null +++ b/themes/black/server/src/lib/routes/password-reset/constants.ts @@ -0,0 +1,2 @@ + +export const CHALLENGE = "reset-password"; \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/password-reset/form/post.spec.ts b/themes/black/server/src/lib/routes/password-reset/form/post.spec.ts new file mode 100644 index 00000000..ed029c90 --- /dev/null +++ b/themes/black/server/src/lib/routes/password-reset/form/post.spec.ts @@ -0,0 +1,122 @@ + +import PasswordResetFormPost = require("./post"); +import { AuthenticationSessionHandler } from "../../../AuthenticationSessionHandler"; +import { AuthenticationSession } from "../../../../../types/AuthenticationSession"; +import { UserDataStore } from "../../../storage/UserDataStore"; +import Sinon = require("sinon"); +import Assert = require("assert"); +import BluebirdPromise = require("bluebird"); +import ExpressMock = require("../../../stubs/express.spec"); +import { ServerVariablesMock, ServerVariablesMockBuilder } from "../../../ServerVariablesMockBuilder.spec"; +import { ServerVariables } from "../../../ServerVariables"; +import { Level } from "../../../authentication/Level"; + +describe("routes/password-reset/form/post", function () { + let req: ExpressMock.RequestMock; + let res: ExpressMock.ResponseMock; + let vars: ServerVariables; + let mocks: ServerVariablesMock; + let authSession: AuthenticationSession; + + beforeEach(function () { + req = { + originalUrl: "/api/password-reset", + body: { + userid: "user" + }, + session: {}, + headers: { + host: "localhost" + } + }; + + const s = ServerVariablesMockBuilder.build(); + mocks = s.mocks; + vars = s.variables; + + const options = { + inMemoryOnly: true + }; + + mocks.userDataStore.saveU2FRegistrationStub.returns(BluebirdPromise.resolve({})); + mocks.userDataStore.retrieveU2FRegistrationStub.returns(BluebirdPromise.resolve({})); + mocks.userDataStore.produceIdentityValidationTokenStub.returns(BluebirdPromise.resolve({})); + mocks.userDataStore.consumeIdentityValidationTokenStub.returns(BluebirdPromise.resolve({})); + + mocks.config.authentication_backend.ldap = { + url: "ldap://ldapjs", + mail_attribute: "mail", + user: "user", + password: "password", + additional_users_dn: "ou=users", + additional_groups_dn: "ou=groups", + base_dn: "dc=example,dc=com", + users_filter: "user", + group_name_attribute: "cn", + groups_filter: "groups" + }; + + res = ExpressMock.ResponseMock(); + authSession = AuthenticationSessionHandler.get(req as any, vars.logger); + authSession.userid = "user"; + authSession.email = "user@example.com"; + authSession.authentication_level = Level.ONE_FACTOR; + }); + + describe("test reset password post", () => { + it("should update the password and reset auth_session for reauthentication", function () { + req.body = {}; + req.body.password = "new-password"; + + mocks.usersDatabase.updatePasswordStub.returns(BluebirdPromise.resolve()); + + authSession.identity_check = { + userid: "user", + challenge: "reset-password" + }; + return PasswordResetFormPost.default(vars)(req as any, res as any) + .then(function () { + return AuthenticationSessionHandler.get(req as any, vars.logger); + }).then(function (_authSession) { + Assert.equal(res.status.getCall(0).args[0], 204); + Assert.equal(_authSession.authentication_level, Level.NOT_AUTHENTICATED); + return BluebirdPromise.resolve(); + }); + }); + + it("should fail if identity_challenge does not exist", function () { + authSession.identity_check = { + userid: "user", + challenge: undefined + }; + return PasswordResetFormPost.default(vars)(req as any, res as any) + .then(function () { + Assert.equal(res.status.getCall(0).args[0], 200); + Assert.deepEqual(res.send.getCall(0).args[0], { + error: "An error occurred during password reset. Your password has not been changed." + }); + }); + }); + + it("should fail when ldap fails", function () { + req.body = {}; + req.body.password = "new-password"; + + mocks.usersDatabase.updatePasswordStub + .returns(BluebirdPromise.reject("Internal error with LDAP")); + + authSession.identity_check = { + challenge: "reset-password", + userid: "user" + }; + return PasswordResetFormPost.default(vars)(req as any, res as any) + .then(function () { + Assert.equal(res.status.getCall(0).args[0], 200); + Assert.deepEqual(res.send.getCall(0).args[0], { + error: "An error occurred during password reset. Your password has not been changed." + }); + return BluebirdPromise.resolve(); + }); + }); + }); +}); diff --git a/themes/black/server/src/lib/routes/password-reset/form/post.ts b/themes/black/server/src/lib/routes/password-reset/form/post.ts new file mode 100644 index 00000000..fccd7471 --- /dev/null +++ b/themes/black/server/src/lib/routes/password-reset/form/post.ts @@ -0,0 +1,50 @@ + +import express = require("express"); +import BluebirdPromise = require("bluebird"); +import objectPath = require("object-path"); +import exceptions = require("../../../Exceptions"); +import { AuthenticationSessionHandler } from "../../../AuthenticationSessionHandler"; +import { AuthenticationSession } from "../../../../../types/AuthenticationSession"; +import ErrorReplies = require("../../../ErrorReplies"); +import UserMessages = require("../../../../../../shared/UserMessages"); +import { ServerVariables } from "../../../ServerVariables"; + +import Constants = require("./../constants"); + +export default function (vars: ServerVariables) { + return function (req: express.Request, res: express.Response): BluebirdPromise { + let authSession: AuthenticationSession; + const newPassword = objectPath.get(req, "body.password"); + + return new BluebirdPromise(function (resolve, reject) { + authSession = AuthenticationSessionHandler.get(req, vars.logger); + if (!authSession.identity_check) { + reject(new Error("No identity check initiated")); + return; + } + + vars.logger.info(req, "User %s wants to reset his/her password.", + authSession.identity_check.userid); + vars.logger.debug(req, "Challenge %s", authSession.identity_check.challenge); + + if (authSession.identity_check.challenge != Constants.CHALLENGE) { + reject(new Error("Bad challenge.")); + return; + } + resolve(); + }) + .then(function () { + return vars.usersDatabase.updatePassword(authSession.identity_check.userid, newPassword); + }) + .then(function () { + vars.logger.info(req, "Password reset for user '%s'", + authSession.identity_check.userid); + AuthenticationSessionHandler.reset(req); + res.status(204); + res.send(); + return BluebirdPromise.resolve(); + }) + .catch(ErrorReplies.replyWithError200(req, res, vars.logger, + UserMessages.RESET_PASSWORD_FAILED)); + }; +} diff --git a/themes/black/server/src/lib/routes/password-reset/identity/PasswordResetHandler.spec.ts b/themes/black/server/src/lib/routes/password-reset/identity/PasswordResetHandler.spec.ts new file mode 100644 index 00000000..ac6a4175 --- /dev/null +++ b/themes/black/server/src/lib/routes/password-reset/identity/PasswordResetHandler.spec.ts @@ -0,0 +1,92 @@ + +import PasswordResetHandler + from "./PasswordResetHandler"; +import { UserDataStore } from "../../../storage/UserDataStore"; +import Sinon = require("sinon"); +import winston = require("winston"); +import assert = require("assert"); +import BluebirdPromise = require("bluebird"); +import ExpressMock = require("../../../stubs/express.spec"); +import { ServerVariablesMock, ServerVariablesMockBuilder } + from "../../../ServerVariablesMockBuilder.spec"; +import { ServerVariables } from "../../../ServerVariables"; + +describe("routes/password-reset/identity/PasswordResetHandler", function () { + let req: ExpressMock.RequestMock; + let res: ExpressMock.ResponseMock; + let mocks: ServerVariablesMock; + let vars: ServerVariables; + + beforeEach(function () { + req = { + originalUrl: "/non-api/xxx", + query: { + userid: "user" + }, + session: { + auth: { + userid: "user", + email: "user@example.com", + first_factor: true, + second_factor: false + } + }, + headers: { + host: "localhost" + } + }; + + const options = { + inMemoryOnly: true + }; + + const s = ServerVariablesMockBuilder.build(); + mocks = s.mocks; + vars = s.variables; + + mocks.userDataStore.saveU2FRegistrationStub + .returns(BluebirdPromise.resolve({})); + mocks.userDataStore.retrieveU2FRegistrationStub + .returns(BluebirdPromise.resolve({})); + mocks.userDataStore.produceIdentityValidationTokenStub + .returns(BluebirdPromise.resolve({})); + mocks.userDataStore.consumeIdentityValidationTokenStub + .returns(BluebirdPromise.resolve({})); + res = ExpressMock.ResponseMock(); + }); + + describe("test reset password identity pre check", () => { + it("should fail when no userid is provided", function () { + req.query.userid = undefined; + const handler = new PasswordResetHandler(vars.logger, + vars.usersDatabase); + return handler.preValidationInit(req as any) + .then(function () { + return BluebirdPromise.reject("It should fail"); + }) + .catch(function (err: Error) { + return BluebirdPromise.resolve(); + }); + }); + + it("should fail if ldap fail", function () { + mocks.usersDatabase.getEmailsStub + .returns(BluebirdPromise.reject("Internal error")); + new PasswordResetHandler(vars.logger, vars.usersDatabase) + .preValidationInit(req as any) + .then(function () { + return BluebirdPromise.reject(new Error("should not be here")); + }, + function (err: Error) { + return BluebirdPromise.resolve(); + }); + }); + + it("should returns identity when ldap replies", function () { + mocks.usersDatabase.getEmailsStub + .returns(BluebirdPromise.resolve(["test@example.com"])); + return new PasswordResetHandler(vars.logger, vars.usersDatabase) + .preValidationInit(req as any); + }); + }); +}); diff --git a/themes/black/server/src/lib/routes/password-reset/identity/PasswordResetHandler.ts b/themes/black/server/src/lib/routes/password-reset/identity/PasswordResetHandler.ts new file mode 100644 index 00000000..42ae92cd --- /dev/null +++ b/themes/black/server/src/lib/routes/password-reset/identity/PasswordResetHandler.ts @@ -0,0 +1,69 @@ +import express = require("express"); +import BluebirdPromise = require("bluebird"); +import objectPath = require("object-path"); + +import exceptions = require("../../../Exceptions"); +import { Identity } from "../../../../../types/Identity"; +import { IdentityValidable } from "../../../IdentityValidable"; +import { PRE_VALIDATION_TEMPLATE } from "../../../IdentityCheckPreValidationTemplate"; +import Constants = require("../constants"); +import { IRequestLogger } from "../../../logging/IRequestLogger"; +import { IUsersDatabase } from "../../../authentication/backends/IUsersDatabase"; + +export const TEMPLATE_NAME = "password-reset-form"; + +export default class PasswordResetHandler implements IdentityValidable { + private logger: IRequestLogger; + private usersDatabase: IUsersDatabase; + + constructor(logger: IRequestLogger, usersDatabase: IUsersDatabase) { + this.logger = logger; + this.usersDatabase = usersDatabase; + } + + challenge(): string { + return Constants.CHALLENGE; + } + + preValidationInit(req: express.Request): BluebirdPromise { + const that = this; + const userid: string = + objectPath.get(req, "query.userid"); + return BluebirdPromise.resolve() + .then(function () { + that.logger.debug(req, "User '%s' requested a password reset", userid); + if (!userid) + return BluebirdPromise.reject( + new exceptions.AccessDeniedError("No user id provided")); + + return that.usersDatabase.getEmails(userid); + }) + .then(function (emails: string[]) { + if (!emails && emails.length <= 0) throw new Error("No email found"); + const identity = { + email: emails[0], + userid: userid + }; + return BluebirdPromise.resolve(identity); + }) + .catch(function (err: Error) { + return BluebirdPromise.reject(new exceptions.IdentityError(err.message)); + }); + } + + preValidationResponse(req: express.Request, res: express.Response) { + res.render(PRE_VALIDATION_TEMPLATE); + } + + postValidationInit(req: express.Request) { + return BluebirdPromise.resolve(); + } + + postValidationResponse(req: express.Request, res: express.Response) { + res.render(TEMPLATE_NAME); + } + + mailSubject(): string { + return "Reset your password"; + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/password-reset/request/get.ts b/themes/black/server/src/lib/routes/password-reset/request/get.ts new file mode 100644 index 00000000..8f3ae2b4 --- /dev/null +++ b/themes/black/server/src/lib/routes/password-reset/request/get.ts @@ -0,0 +1,13 @@ + +import express = require("express"); +import BluebirdPromise = require("bluebird"); +import objectPath = require("object-path"); +import exceptions = require("../../../Exceptions"); + +import Constants = require("./../constants"); + +const TEMPLATE_NAME = "password-reset-request"; + +export default function (req: express.Request, res: express.Response) { + res.render(TEMPLATE_NAME); +} \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/secondfactor/get.spec.ts b/themes/black/server/src/lib/routes/secondfactor/get.spec.ts new file mode 100644 index 00000000..6c77e1f6 --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/get.spec.ts @@ -0,0 +1,44 @@ +import SecondFactorGet from "./get"; +import { ServerVariablesMockBuilder, ServerVariablesMock } + from "../../ServerVariablesMockBuilder.spec"; +import { ServerVariables } from "../../ServerVariables"; +import Sinon = require("sinon"); +import ExpressMock = require("../../stubs/express.spec"); +import Assert = require("assert"); +import Endpoints = require("../../../../../shared/api"); +import BluebirdPromise = require("bluebird"); + +describe("routes/secondfactor/get", function () { + let mocks: ServerVariablesMock; + let vars: ServerVariables; + let req: ExpressMock.RequestMock; + let res: ExpressMock.ResponseMock; + + beforeEach(function () { + const s = ServerVariablesMockBuilder.build(); + mocks = s.mocks; + vars = s.variables; + + req = ExpressMock.RequestMock(); + res = ExpressMock.ResponseMock(); + + req.session = { + auth: { + userid: "user", + first_factor: true, + second_factor: false + } + }; + }); + + describe("test rendering", function () { + it("should render second factor page", function () { + req.session.auth.second_factor = false; + return SecondFactorGet(vars)(req as any, res as any) + .then(function () { + Assert(res.render.calledWith("secondfactor")); + return BluebirdPromise.resolve(); + }); + }); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/secondfactor/get.ts b/themes/black/server/src/lib/routes/secondfactor/get.ts new file mode 100644 index 00000000..9f6deb4c --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/get.ts @@ -0,0 +1,28 @@ + +import Express = require("express"); +import Endpoints = require("../../../../../shared/api"); +import BluebirdPromise = require("bluebird"); +import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler"; +import { ServerVariables } from "../../ServerVariables"; + +const TEMPLATE_NAME = "secondfactor"; + +export default function (vars: ServerVariables) { + function handler(req: Express.Request, res: Express.Response) + : BluebirdPromise { + + return new BluebirdPromise(function (resolve, reject) { + const authSession = AuthenticationSessionHandler.get(req, vars.logger); + + res.render(TEMPLATE_NAME, { + username: authSession.userid, + totp_identity_start_endpoint: + Endpoints.SECOND_FACTOR_TOTP_IDENTITY_START_GET, + u2f_identity_start_endpoint: + Endpoints.SECOND_FACTOR_U2F_IDENTITY_START_GET + }); + resolve(); + }); + } + return handler; +} diff --git a/themes/black/server/src/lib/routes/secondfactor/redirect.spec.ts b/themes/black/server/src/lib/routes/secondfactor/redirect.spec.ts new file mode 100644 index 00000000..ea66e6dc --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/redirect.spec.ts @@ -0,0 +1,41 @@ +import Redirect from "./redirect"; +import ExpressMock = require("../../stubs/express.spec"); +import { ServerVariablesMockBuilder, ServerVariablesMock } +from "../../ServerVariablesMockBuilder.spec"; +import { ServerVariables } from "../../ServerVariables"; +import Assert = require("assert"); + +describe("routes/secondfactor/redirect", function() { + let req: ExpressMock.RequestMock; + let res: ExpressMock.ResponseMock; + let mocks: ServerVariablesMock; + let vars: ServerVariables; + + beforeEach(function () { + const s = ServerVariablesMockBuilder.build(); + mocks = s.mocks; + vars = s.variables; + + req = ExpressMock.RequestMock(); + res = ExpressMock.ResponseMock(); + }); + + it("should redirect to default_redirection_url", function() { + vars.config.default_redirection_url = "http://default_redirection_url"; + Redirect(vars)(req as any, res as any) + .then(function() { + Assert(res.json.calledWith({ + redirect: "http://default_redirection_url" + })); + }); + }); + + it("should redirect to /", function() { + Redirect(vars)(req as any, res as any) + .then(function() { + Assert(res.json.calledWith({ + redirect: "/" + })); + }); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/secondfactor/redirect.ts b/themes/black/server/src/lib/routes/secondfactor/redirect.ts new file mode 100644 index 00000000..5d84d9eb --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/redirect.ts @@ -0,0 +1,30 @@ + +import express = require("express"); +import objectPath = require("object-path"); +import Endpoints = require("../../../../../shared/api"); +import { ServerVariables } from "../../ServerVariables"; +import BluebirdPromise = require("bluebird"); +import ErrorReplies = require("../../ErrorReplies"); +import UserMessages = require("../../../../../shared/UserMessages"); +import { RedirectionMessage } from "../../../../../shared/RedirectionMessage"; +import Constants = require("../../../../../shared/constants"); + +export default function (vars: ServerVariables) { + return function (req: express.Request, res: express.Response) + : BluebirdPromise { + + return new BluebirdPromise(function (resolve, reject) { + let redirectUrl: string = "/"; + if (vars.config.default_redirection_url) { + redirectUrl = vars.config.default_redirection_url; + } + vars.logger.debug(req, "Request redirection to \"%s\".", redirectUrl); + res.json({ + redirect: redirectUrl + } as RedirectionMessage); + return resolve(); + }) + .catch(ErrorReplies.replyWithError200(req, res, vars.logger, + UserMessages.OPERATION_FAILED)); + }; +} diff --git a/themes/black/server/src/lib/routes/secondfactor/totp/constants.ts b/themes/black/server/src/lib/routes/secondfactor/totp/constants.ts new file mode 100644 index 00000000..7b5a1dcf --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/totp/constants.ts @@ -0,0 +1,4 @@ + +export const CHALLENGE = "totp-register"; +export const TEMPLATE_NAME = "totp-register"; + diff --git a/themes/black/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.spec.ts b/themes/black/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.spec.ts new file mode 100644 index 00000000..78b8ea3e --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.spec.ts @@ -0,0 +1,116 @@ +import Sinon = require("sinon"); +import RegistrationHandler from "./RegistrationHandler"; +import { Identity } from "../../../../../../types/Identity"; +import { UserDataStore } from "../../../../storage/UserDataStore"; +import BluebirdPromise = require("bluebird"); +import ExpressMock = require("../../../../stubs/express.spec"); +import { ServerVariablesMock, ServerVariablesMockBuilder } + from "../../../../ServerVariablesMockBuilder.spec"; +import { ServerVariables } from "../../../../ServerVariables"; +import Assert = require("assert"); + +describe("routes/secondfactor/totp/identity/RegistrationHandler", function () { + let req: ExpressMock.RequestMock; + let res: ExpressMock.ResponseMock; + let mocks: ServerVariablesMock; + let vars: ServerVariables; + + beforeEach(function () { + const s = ServerVariablesMockBuilder.build(); + mocks = s.mocks; + vars = s.variables; + + req = ExpressMock.RequestMock(); + req.session = { + auth: { + userid: "user", + email: "user@example.com", + first_factor: true, + second_factor: false, + identity_check: { + userid: "user", + challenge: "totp-register" + } + } + }; + req.headers = {}; + req.headers.host = "localhost"; + + const options = { + inMemoryOnly: true + }; + + mocks.userDataStore.saveU2FRegistrationStub + .returns(BluebirdPromise.resolve({})); + mocks.userDataStore.retrieveU2FRegistrationStub + .returns(BluebirdPromise.resolve({})); + mocks.userDataStore.produceIdentityValidationTokenStub + .returns(BluebirdPromise.resolve({})); + mocks.userDataStore.consumeIdentityValidationTokenStub + .returns(BluebirdPromise.resolve({})); + mocks.userDataStore.saveTOTPSecretStub + .returns(BluebirdPromise.resolve({})); + + res = ExpressMock.ResponseMock(); + }); + + describe("test totp registration pre validation", function () { + it("should fail if first_factor has not been passed", function () { + req.session.auth.first_factor = false; + return new RegistrationHandler(vars.logger, vars.userDataStore, + vars.totpHandler, vars.config.totp) + .preValidationInit(req as any) + .then(function () { + return BluebirdPromise.reject(new Error("It should fail")); + }) + .catch(function (err: Error) { + return BluebirdPromise.resolve(); + }); + }); + + it("should fail if userid is missing", function (done) { + req.session.auth.first_factor = false; + req.session.auth.userid = undefined; + + new RegistrationHandler(vars.logger, vars.userDataStore, vars.totpHandler, + vars.config.totp) + .preValidationInit(req as any) + .catch(function (err: Error) { + done(); + }); + }); + + it("should fail if email is missing", function (done) { + req.session.auth.first_factor = false; + req.session.auth.email = undefined; + + new RegistrationHandler(vars.logger, vars.userDataStore, vars.totpHandler, + vars.config.totp) + .preValidationInit(req as any) + .catch(function (err: Error) { + done(); + }); + }); + + it("should succeed if first factor passed, userid and email are provided", + function () { + return new RegistrationHandler(vars.logger, vars.userDataStore, + vars.totpHandler, vars.config.totp) + .preValidationInit(req as any); + }); + }); + + describe("test totp registration post validation", function () { + it("should generate a secret using userId as label and issuer defined in config", function () { + vars.config.totp = { + issuer: "issuer" + }; + return new RegistrationHandler(vars.logger, vars.userDataStore, + vars.totpHandler, vars.config.totp) + .postValidationResponse(req as any, res as any) + .then(function() { + Assert(mocks.totpHandler.generateStub.calledWithExactly("user", "issuer")); + }); + }); + }); +}); diff --git a/themes/black/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.ts b/themes/black/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.ts new file mode 100644 index 00000000..b39b6d04 --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.ts @@ -0,0 +1,112 @@ + +import express = require("express"); +import BluebirdPromise = require("bluebird"); +import objectPath = require("object-path"); + +import { Identity } from "../../../../../../types/Identity"; +import { IdentityValidable } from "../../../../IdentityValidable"; +import { PRE_VALIDATION_TEMPLATE } from "../../../../IdentityCheckPreValidationTemplate"; +import Constants = require("../constants"); +import Endpoints = require("../../../../../../../shared/api"); +import ErrorReplies = require("../../../../ErrorReplies"); +import { AuthenticationSessionHandler } from "../../../../AuthenticationSessionHandler"; +import UserMessages = require("../../../../../../../shared/UserMessages"); +import FirstFactorValidator = require("../../../../FirstFactorValidator"); +import { IRequestLogger } from "../../../../logging/IRequestLogger"; +import { IUserDataStore } from "../../../../storage/IUserDataStore"; +import { ITotpHandler } from "../../../../authentication/totp/ITotpHandler"; +import { TOTPSecret } from "../../../../../../types/TOTPSecret"; +import { TotpConfiguration } from "../../../../configuration/schema/TotpConfiguration"; + + +export default class RegistrationHandler implements IdentityValidable { + private logger: IRequestLogger; + private userDataStore: IUserDataStore; + private totp: ITotpHandler; + private configuration: TotpConfiguration; + + constructor(logger: IRequestLogger, + userDataStore: IUserDataStore, + totp: ITotpHandler, configuration: TotpConfiguration) { + this.logger = logger; + this.userDataStore = userDataStore; + this.totp = totp; + this.configuration = configuration; + } + + challenge(): string { + return Constants.CHALLENGE; + } + + private retrieveIdentity(req: express.Request): BluebirdPromise { + const that = this; + return new BluebirdPromise(function (resolve, reject) { + const authSession = AuthenticationSessionHandler.get(req, that.logger); + const userid = authSession.userid; + const email = authSession.email; + + if (!(userid && email)) { + return reject(new Error("User ID or email is missing")); + } + + const identity = { + email: email, + userid: userid + }; + return resolve(identity); + }); + } + + preValidationInit(req: express.Request): BluebirdPromise { + const that = this; + return FirstFactorValidator.validate(req, this.logger) + .then(function () { + return that.retrieveIdentity(req); + }); + } + + preValidationResponse(req: express.Request, res: express.Response) { + res.render(PRE_VALIDATION_TEMPLATE); + } + + postValidationInit(req: express.Request) { + return FirstFactorValidator.validate(req, this.logger); + } + + postValidationResponse(req: express.Request, res: express.Response) + : BluebirdPromise { + const that = this; + let secret: TOTPSecret; + let userId: string; + return new BluebirdPromise(function (resolve, reject) { + const authSession = AuthenticationSessionHandler.get(req, that.logger); + userId = authSession.userid; + + if (authSession.identity_check.challenge != Constants.CHALLENGE + || !userId) + return reject(new Error("Bad challenge.")); + + resolve(); + }) + .then(function () { + secret = that.totp.generate(userId, + that.configuration.issuer); + that.logger.debug(req, "Save the TOTP secret in DB"); + return that.userDataStore.saveTOTPSecret(userId, secret); + }) + .then(function () { + AuthenticationSessionHandler.reset(req); + + res.render(Constants.TEMPLATE_NAME, { + base32_secret: secret.base32, + otpauth_url: secret.otpauth_url, + login_endpoint: Endpoints.FIRST_FACTOR_GET + }); + }) + .catch(ErrorReplies.replyWithError200(req, res, that.logger, UserMessages.OPERATION_FAILED)); + } + + mailSubject(): string { + return "Set up Authelia's one-time password"; + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/secondfactor/totp/sign/post.spec.ts b/themes/black/server/src/lib/routes/secondfactor/totp/sign/post.spec.ts new file mode 100644 index 00000000..70a20d39 --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/totp/sign/post.spec.ts @@ -0,0 +1,76 @@ + +import BluebirdPromise = require("bluebird"); +import Sinon = require("sinon"); +import Assert = require("assert"); +import Exceptions = require("../../../../Exceptions"); +import { AuthenticationSessionHandler } from "../../../../AuthenticationSessionHandler"; +import { AuthenticationSession } from "../../../../../../types/AuthenticationSession"; +import SignPost = require("./post"); +import { ServerVariables } from "../../../../ServerVariables"; + +import ExpressMock = require("../../../../stubs/express.spec"); +import { UserDataStoreStub } from "../../../../storage/UserDataStoreStub.spec"; +import { ServerVariablesMock, ServerVariablesMockBuilder } from "../../../../ServerVariablesMockBuilder.spec"; +import { Level } from "../../../../authentication/Level"; + +describe("routes/secondfactor/totp/sign/post", function () { + let req: ExpressMock.RequestMock; + let res: ExpressMock.ResponseMock; + let authSession: AuthenticationSession; + let vars: ServerVariables; + let mocks: ServerVariablesMock; + + beforeEach(function () { + const s = ServerVariablesMockBuilder.build(); + vars = s.variables; + mocks = s.mocks; + const app_get = Sinon.stub(); + req = { + originalUrl: "/api/totp-register", + app: {}, + body: { + token: "abc" + }, + session: {}, + query: { + redirect: "http://redirect" + } + }; + res = ExpressMock.ResponseMock(); + + const doc = { + userid: "user", + secret: { + base32: "ABCDEF" + } + }; + mocks.userDataStore.retrieveTOTPSecretStub.returns(BluebirdPromise.resolve(doc)); + authSession = AuthenticationSessionHandler.get(req as any, vars.logger); + authSession.userid = "user"; + authSession.authentication_level = Level.ONE_FACTOR; + }); + + + it("should send status code 200 when totp is valid", function () { + mocks.totpHandler.validateStub.returns(true); + return SignPost.default(vars)(req as any, res as any) + .then(function () { + Assert.equal(authSession.authentication_level, Level.TWO_FACTOR); + return BluebirdPromise.resolve(); + }); + }); + + it("should send error message when totp is not valid", function () { + mocks.totpHandler.validateStub.returns(false); + return SignPost.default(vars)(req as any, res as any) + .then(function () { + Assert.notEqual(authSession.authentication_level, Level.TWO_FACTOR); + Assert.equal(res.status.getCall(0).args[0], 200); + Assert.deepEqual(res.send.getCall(0).args[0], { + error: "Operation failed." + }); + return BluebirdPromise.resolve(); + }); + }); +}); + diff --git a/themes/black/server/src/lib/routes/secondfactor/totp/sign/post.ts b/themes/black/server/src/lib/routes/secondfactor/totp/sign/post.ts new file mode 100644 index 00000000..34a276d1 --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/totp/sign/post.ts @@ -0,0 +1,42 @@ +import Bluebird = require("bluebird"); +import Express = require("express"); + +import { TOTPSecretDocument } from "../../../../storage/TOTPSecretDocument"; +import Endpoints = require("../../../../../../../shared/api"); +import Redirect from "../../redirect"; +import ErrorReplies = require("../../../../ErrorReplies"); +import { AuthenticationSessionHandler } from "../../../../AuthenticationSessionHandler"; +import { AuthenticationSession } from "../../../../../../types/AuthenticationSession"; +import UserMessages = require("../../../../../../../shared/UserMessages"); +import { ServerVariables } from "../../../../ServerVariables"; +import { Level } from "../../../../authentication/Level"; + +const UNAUTHORIZED_MESSAGE = "Unauthorized access"; + +export default function (vars: ServerVariables) { + function handler(req: Express.Request, res: Express.Response): Bluebird { + let authSession: AuthenticationSession; + const token = req.body.token; + + return new Bluebird(function (resolve, reject) { + authSession = AuthenticationSessionHandler.get(req, vars.logger); + vars.logger.info(req, "Initiate TOTP validation for user \"%s\".", authSession.userid); + resolve(); + }) + .then(function () { + return vars.userDataStore.retrieveTOTPSecret(authSession.userid); + }) + .then(function (doc: TOTPSecretDocument) { + if (!vars.totpHandler.validate(token, doc.secret.base32)) + return Bluebird.reject(new Error("Invalid TOTP token.")); + + vars.logger.debug(req, "TOTP validation succeeded."); + authSession.authentication_level = Level.TWO_FACTOR; + Redirect(vars)(req, res); + return Bluebird.resolve(); + }) + .catch(ErrorReplies.replyWithError200(req, res, vars.logger, + UserMessages.OPERATION_FAILED)); + } + return handler; +} diff --git a/themes/black/server/src/lib/routes/secondfactor/u2f/U2FCommon.ts b/themes/black/server/src/lib/routes/secondfactor/u2f/U2FCommon.ts new file mode 100644 index 00000000..7f16c0ee --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/u2f/U2FCommon.ts @@ -0,0 +1,11 @@ + +import util = require("util"); +import express = require("express"); + +function extract_app_id(req: express.Request): string { + return util.format("https://%s", req.headers.host); +} + +export = { + extract_app_id: extract_app_id +}; \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.spec.ts b/themes/black/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.spec.ts new file mode 100644 index 00000000..a54bfbfe --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.spec.ts @@ -0,0 +1,96 @@ +import Sinon = require("sinon"); +import Assert = require("assert"); +import BluebirdPromise = require("bluebird"); + +import { Identity } from "../../../../../../types/Identity"; +import RegistrationHandler from "./RegistrationHandler"; +import ExpressMock = require("../../../../stubs/express.spec"); +import { UserDataStoreStub } from "../../../../storage/UserDataStoreStub.spec"; +import { ServerVariablesMock, ServerVariablesMockBuilder } from "../../../../ServerVariablesMockBuilder.spec"; +import { ServerVariables } from "../../../../ServerVariables"; + +describe("routes/secondfactor/u2f/identity/RegistrationHandler", function () { + let req: ExpressMock.RequestMock; + let res: ExpressMock.ResponseMock; + let mocks: ServerVariablesMock; + let vars: ServerVariables; + + beforeEach(function () { + const s = ServerVariablesMockBuilder.build(); + mocks = s.mocks; + vars = s.variables; + + req = ExpressMock.RequestMock(); + req.app = {}; + req.session = { + auth: { + userid: "user", + email: "user@example.com", + first_factor: true, + second_factor: false + } + }; + req.headers = {}; + req.headers.host = "localhost"; + + const options = { + inMemoryOnly: true + }; + + mocks.userDataStore.saveU2FRegistrationStub.returns(BluebirdPromise.resolve({})); + mocks.userDataStore.retrieveU2FRegistrationStub.returns(BluebirdPromise.resolve({})); + mocks.userDataStore.produceIdentityValidationTokenStub.returns(BluebirdPromise.resolve({})); + mocks.userDataStore.consumeIdentityValidationTokenStub.returns(BluebirdPromise.resolve({})); + + res = ExpressMock.ResponseMock(); + res.send = Sinon.spy(); + res.json = Sinon.spy(); + res.status = Sinon.spy(); + }); + + describe("test u2f registration check", test_registration_check); + + function test_registration_check() { + it("should fail if first_factor has not been passed", function () { + req.session.auth.first_factor = false; + return new RegistrationHandler(vars.logger).preValidationInit(req as any) + .then(function () { return BluebirdPromise.reject(new Error("It should fail")); }) + .catch(function (err: Error) { + return BluebirdPromise.resolve(); + }); + }); + + it("should fail if userid is missing", function () { + req.session.auth.first_factor = false; + req.session.auth.userid = undefined; + + return new RegistrationHandler(vars.logger).preValidationInit(req as any) + .then(function () { + return BluebirdPromise.reject(new Error("should not be here")); + }, + function (err: Error) { + return BluebirdPromise.resolve(); + }); + }); + + it("should fail if email is missing", function () { + req.session.auth.first_factor = false; + req.session.auth.email = undefined; + + return new RegistrationHandler(vars.logger).preValidationInit(req as any) + .then(function () { + return BluebirdPromise.reject(new Error("should not be here")); + }, + function (err: Error) { + return BluebirdPromise.resolve(); + }); + }); + + it("should succeed if first factor passed, userid and email are provided", function () { + req.session.auth.first_factor = true; + req.session.auth.email = "admin@example.com"; + req.session.auth.userid = "user"; + return new RegistrationHandler(vars.logger).preValidationInit(req as any); + }); + } +}); diff --git a/themes/black/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.ts b/themes/black/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.ts new file mode 100644 index 00000000..bc4713c7 --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.ts @@ -0,0 +1,73 @@ + +import BluebirdPromise = require("bluebird"); +import express = require("express"); +import objectPath = require("object-path"); + +import { IdentityValidable } from "../../../../IdentityValidable"; +import { Identity } from "../../../../../../types/Identity"; +import { PRE_VALIDATION_TEMPLATE } from "../../../../IdentityCheckPreValidationTemplate"; +import FirstFactorValidator = require("../../../../FirstFactorValidator"); +import { AuthenticationSessionHandler } from "../../../../AuthenticationSessionHandler"; +import { IRequestLogger } from "../../../../logging/IRequestLogger"; + +const CHALLENGE = "u2f-register"; +const MAIL_SUBJECT = "Register your security key with Authelia"; + +const POST_VALIDATION_TEMPLATE_NAME = "u2f-register"; + + +export default class RegistrationHandler implements IdentityValidable { + private logger: IRequestLogger; + + constructor(logger: IRequestLogger) { + this.logger = logger; + } + + challenge(): string { + return CHALLENGE; + } + + private retrieveIdentity(req: express.Request): BluebirdPromise { + const that = this; + return new BluebirdPromise(function(resolve, reject) { + const authSession = AuthenticationSessionHandler.get(req, that.logger); + const userid = authSession.userid; + const email = authSession.email; + + if (!(userid && email)) { + return reject(new Error("User ID or email is missing")); + } + + const identity = { + email: email, + userid: userid + }; + return resolve(identity); + }); + } + + preValidationInit(req: express.Request): BluebirdPromise { + const that = this; + return FirstFactorValidator.validate(req, this.logger) + .then(function () { + return that.retrieveIdentity(req); + }); + } + + preValidationResponse(req: express.Request, res: express.Response) { + res.render(PRE_VALIDATION_TEMPLATE); + } + + postValidationInit(req: express.Request) { + return FirstFactorValidator.validate(req, this.logger); + } + + postValidationResponse(req: express.Request, res: express.Response) { + res.render(POST_VALIDATION_TEMPLATE_NAME); + } + + mailSubject(): string { + return MAIL_SUBJECT; + } +} + diff --git a/themes/black/server/src/lib/routes/secondfactor/u2f/register/post.spec.ts b/themes/black/server/src/lib/routes/secondfactor/u2f/register/post.spec.ts new file mode 100644 index 00000000..de3347a2 --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/u2f/register/post.spec.ts @@ -0,0 +1,146 @@ + +import sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); +import assert = require("assert"); +import U2FRegisterPost = require("./post"); +import { AuthenticationSessionHandler } from "../../../../AuthenticationSessionHandler"; +import { AuthenticationSession } from "../../../../../../types/AuthenticationSession"; +import ExpressMock = require("../../../../stubs/express.spec"); +import { UserDataStoreStub } from "../../../../storage/UserDataStoreStub.spec"; +import { ServerVariablesMockBuilder, ServerVariablesMock } from "../../../../ServerVariablesMockBuilder.spec"; +import { ServerVariables } from "../../../../ServerVariables"; + + +describe("routes/secondfactor/u2f/register/post", function () { + let req: ExpressMock.RequestMock; + let res: ExpressMock.ResponseMock; + let mocks: ServerVariablesMock; + let vars: ServerVariables; + let authSession: AuthenticationSession; + + beforeEach(function () { + req = ExpressMock.RequestMock(); + req.originalUrl = "/api/xxxx"; + req.app = {}; + req.session = { + auth: { + userid: "user", + first_factor: true, + second_factor: false, + identity_check: { + challenge: "u2f-register", + userid: "user" + } + } + }; + req.headers = {}; + req.headers.host = "localhost"; + + const s = ServerVariablesMockBuilder.build(); + mocks = s.mocks; + vars = s.variables; + + const options = { + inMemoryOnly: true + }; + + mocks.userDataStore.saveU2FRegistrationStub.returns(BluebirdPromise.resolve({})); + mocks.userDataStore.retrieveU2FRegistrationStub.returns(BluebirdPromise.resolve({})); + + res = ExpressMock.ResponseMock(); + res.send = sinon.spy(); + res.json = sinon.spy(); + res.status = sinon.spy(); + + authSession = AuthenticationSessionHandler.get(req as any, vars.logger); + }); + + describe("test registration", test_registration); + + + function test_registration() { + it("should save u2f meta and return status code 200", function () { + const expectedStatus = { + keyHandle: "keyHandle", + publicKey: "pbk", + certificate: "cert" + }; + mocks.u2f.checkRegistrationStub.returns(BluebirdPromise.resolve(expectedStatus)); + + authSession.register_request = { + appId: "app", + challenge: "challenge", + keyHandle: "key", + version: "U2F_V2" + }; + return U2FRegisterPost.default(vars)(req as any, res as any) + .then(function () { + assert.equal("user", mocks.userDataStore.saveU2FRegistrationStub.getCall(0).args[0]); + assert.equal(authSession.identity_check, undefined); + }); + }); + + it("should return error message on finishRegistration error", function () { + mocks.u2f.checkRegistrationStub.returns({ errorCode: 500 }); + + authSession.register_request = { + appId: "app", + challenge: "challenge", + keyHandle: "key", + version: "U2F_V2" + }; + + return U2FRegisterPost.default(vars)(req as any, res as any) + .then(function () { return BluebirdPromise.reject(new Error("It should fail")); }) + .catch(function () { + assert.equal(200, res.status.getCall(0).args[0]); + assert.deepEqual(res.send.getCall(0).args[0], { + error: "Operation failed." + }); + return BluebirdPromise.resolve(); + }); + }); + + it("should return error message when register_request is not provided", function () { + mocks.u2f.checkRegistrationStub.returns(BluebirdPromise.resolve()); + authSession.register_request = undefined; + return U2FRegisterPost.default(vars)(req as any, res as any) + .then(function () { return BluebirdPromise.reject(new Error("It should fail")); }) + .catch(function () { + assert.equal(200, res.status.getCall(0).args[0]); + assert.deepEqual(res.send.getCall(0).args[0], { + error: "Operation failed." + }); + return BluebirdPromise.resolve(); + }); + }); + + it("should return error message when no auth request has been initiated", function () { + mocks.u2f.checkRegistrationStub.returns(BluebirdPromise.resolve()); + authSession.register_request = undefined; + return U2FRegisterPost.default(vars)(req as any, res as any) + .then(function () { return BluebirdPromise.reject(new Error("It should fail")); }) + .catch(function () { + assert.equal(200, res.status.getCall(0).args[0]); + assert.deepEqual(res.send.getCall(0).args[0], { + error: "Operation failed." + }); + return BluebirdPromise.resolve(); + }); + }); + + it("should return error message when identity has not been verified", function () { + authSession.identity_check = undefined; + return U2FRegisterPost.default(vars)(req as any, res as any) + .then(function () { return BluebirdPromise.reject(new Error("It should fail")); }) + .catch(function () { + assert.equal(200, res.status.getCall(0).args[0]); + assert.deepEqual(res.send.getCall(0).args[0], { + error: "Operation failed." + }); + return BluebirdPromise.resolve(); + }); + }); + } +}); + diff --git a/themes/black/server/src/lib/routes/secondfactor/u2f/register/post.ts b/themes/black/server/src/lib/routes/secondfactor/u2f/register/post.ts new file mode 100644 index 00000000..7296ccbe --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/u2f/register/post.ts @@ -0,0 +1,64 @@ + +import { UserDataStore } from "../../../../storage/UserDataStore"; +import objectPath = require("object-path"); +import u2f_common = require("../U2FCommon"); +import BluebirdPromise = require("bluebird"); +import express = require("express"); +import U2f = require("u2f"); +import { U2FRegistration } from "../../../../../../types/U2FRegistration"; +import redirect from "../../redirect"; +import ErrorReplies = require("../../../../ErrorReplies"); +import { ServerVariables } from "../../../../ServerVariables"; +import { AuthenticationSessionHandler } from "../../../../AuthenticationSessionHandler"; +import UserMessages = require("../../../../../../../shared/UserMessages"); +import { AuthenticationSession } from "../../../../../../types/AuthenticationSession"; + + +export default function (vars: ServerVariables) { + function handler(req: express.Request, res: express.Response): BluebirdPromise { + let authSession: AuthenticationSession; + const appid = u2f_common.extract_app_id(req); + const registrationResponse: U2f.RegistrationData = req.body; + + return new BluebirdPromise(function (resolve, reject) { + authSession = AuthenticationSessionHandler.get(req, vars.logger); + const registrationRequest = authSession.register_request; + + if (!registrationRequest) { + return reject(new Error("No registration request")); + } + + if (!authSession.identity_check + || authSession.identity_check.challenge != "u2f-register") { + return reject(new Error("Bad challenge for registration request")); + } + + vars.logger.info(req, "Finishing registration"); + vars.logger.debug(req, "RegistrationRequest = %s", JSON.stringify(registrationRequest)); + vars.logger.debug(req, "RegistrationResponse = %s", JSON.stringify(registrationResponse)); + + return resolve(vars.u2f.checkRegistration(registrationRequest, registrationResponse)); + }) + .then(function (u2fResult: U2f.RegistrationResult | U2f.Error): BluebirdPromise { + if (objectPath.has(u2fResult, "errorCode")) + return BluebirdPromise.reject(new Error("Error while registering.")); + + const registrationResult: U2f.RegistrationResult = u2fResult as U2f.RegistrationResult; + vars.logger.info(req, "Store registration and reply"); + vars.logger.debug(req, "RegistrationResult = %s", JSON.stringify(registrationResult)); + const registration: U2FRegistration = { + keyHandle: registrationResult.keyHandle, + publicKey: registrationResult.publicKey + }; + return vars.userDataStore.saveU2FRegistration(authSession.userid, appid, registration); + }) + .then(function () { + authSession.identity_check = undefined; + redirect(vars)(req, res); + return BluebirdPromise.resolve(); + }) + .catch(ErrorReplies.replyWithError200(req, res, vars.logger, + UserMessages.OPERATION_FAILED)); + } + return handler; +} diff --git a/themes/black/server/src/lib/routes/secondfactor/u2f/register_request/get.spec.ts b/themes/black/server/src/lib/routes/secondfactor/u2f/register_request/get.spec.ts new file mode 100644 index 00000000..a207c910 --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/u2f/register_request/get.spec.ts @@ -0,0 +1,86 @@ + +import sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); +import Assert = require("assert"); +import U2FRegisterRequestGet = require("./get"); +import ExpressMock = require("../../../../stubs/express.spec"); +import { UserDataStoreStub } from "../../../../storage/UserDataStoreStub.spec"; +import { ServerVariablesMockBuilder, ServerVariablesMock } from "../../../../ServerVariablesMockBuilder.spec"; +import { ServerVariables } from "../../../../ServerVariables"; + +describe("routes/secondfactor/u2f/register_request/get", function () { + let req: ExpressMock.RequestMock; + let res: ExpressMock.ResponseMock; + let mocks: ServerVariablesMock; + let vars: ServerVariables; + + beforeEach(function () { + req = ExpressMock.RequestMock(); + req.originalUrl = "/api/xxxx"; + req.app = {}; + req.session = { + auth: { + userid: "user", + first_factor: true, + second_factor: false, + identity_check: { + challenge: "u2f-register", + userid: "user" + } + } + }; + req.headers = {}; + req.headers.host = "localhost"; + + const s = ServerVariablesMockBuilder.build(); + mocks = s.mocks; + vars = s.variables; + + const options = { + inMemoryOnly: true + }; + + mocks.userDataStore.saveU2FRegistrationStub.returns(BluebirdPromise.resolve({})); + mocks.userDataStore.retrieveU2FRegistrationStub.returns(BluebirdPromise.resolve({})); + + res = ExpressMock.ResponseMock(); + res.send = sinon.spy(); + res.json = sinon.spy(); + res.status = sinon.spy(); + }); + + describe("test registration request", () => { + it("should send back the registration request and save it in the session", function () { + const expectedRequest = { + test: "abc" + }; + mocks.u2f.requestStub.returns(BluebirdPromise.resolve(expectedRequest)); + return U2FRegisterRequestGet.default(vars)(req as any, res as any) + .then(function () { + Assert.deepEqual(expectedRequest, res.json.getCall(0).args[0]); + }); + }); + + it("should return internal error on registration request", function () { + res.send = sinon.spy(); + const user_key_container = {}; + mocks.u2f.requestStub.returns(BluebirdPromise.reject("Internal error")); + return U2FRegisterRequestGet.default(vars)(req as any, res as any) + .then(function () { + Assert.equal(res.status.getCall(0).args[0], 200); + Assert.deepEqual(res.send.getCall(0).args[0], { + error: "Operation failed." + }); + }); + }); + + it("should return forbidden if identity has not been verified", function () { + req.session.auth.identity_check = undefined; + return U2FRegisterRequestGet.default(vars)(req as any, res as any) + .then(function () { + Assert.equal(403, res.status.getCall(0).args[0]); + }); + }); + }); +}); + diff --git a/themes/black/server/src/lib/routes/secondfactor/u2f/register_request/get.ts b/themes/black/server/src/lib/routes/secondfactor/u2f/register_request/get.ts new file mode 100644 index 00000000..f611af93 --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/u2f/register_request/get.ts @@ -0,0 +1,43 @@ + +import { UserDataStore } from "../../../../storage/UserDataStore"; + +import objectPath = require("object-path"); +import u2f_common = require("../U2FCommon"); +import BluebirdPromise = require("bluebird"); +import express = require("express"); +import U2f = require("u2f"); +import ErrorReplies = require("../../../../ErrorReplies"); +import { AuthenticationSessionHandler } from "../../../../AuthenticationSessionHandler"; +import { AuthenticationSession } from "../../../../../../types/AuthenticationSession"; +import UserMessages = require("../../../../../../../shared/UserMessages"); +import { ServerVariables } from "../../../../ServerVariables"; + +export default function (vars: ServerVariables) { + function handler(req: express.Request, res: express.Response): BluebirdPromise { + let authSession: AuthenticationSession; + const appid: string = u2f_common.extract_app_id(req); + + return new BluebirdPromise(function (resolve, reject) { + authSession = AuthenticationSessionHandler.get(req, vars.logger); + if (!authSession.identity_check + || authSession.identity_check.challenge != "u2f-register") { + res.status(403); + res.send(); + return reject(new Error("Bad challenge.")); + } + + vars.logger.info(req, "Starting registration for appId '%s'", appid); + return resolve(vars.u2f.request(appid)); + }) + .then(function (registrationRequest: U2f.Request) { + vars.logger.debug(req, "RegistrationRequest = %s", JSON.stringify(registrationRequest)); + authSession.register_request = registrationRequest; + res.json(registrationRequest); + return BluebirdPromise.resolve(); + }) + .catch(ErrorReplies.replyWithError200(req, res, vars.logger, + UserMessages.OPERATION_FAILED)); + } + + return handler; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/secondfactor/u2f/sign/post.spec.ts b/themes/black/server/src/lib/routes/secondfactor/u2f/sign/post.spec.ts new file mode 100644 index 00000000..9b137e66 --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/u2f/sign/post.spec.ts @@ -0,0 +1,101 @@ + +import sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); +import Assert = require("assert"); +import U2FSignPost = require("./post"); +import { ServerVariables } from "../../../../ServerVariables"; +import winston = require("winston"); + +import { ServerVariablesMockBuilder, ServerVariablesMock } from "../../../../ServerVariablesMockBuilder.spec"; +import ExpressMock = require("../../../../stubs/express.spec"); +import U2FMock = require("../../../../stubs/u2f.spec"); +import U2f = require("u2f"); +import { Level } from "../../../../authentication/Level"; + +describe("routes/secondfactor/u2f/sign/post", function () { + let req: ExpressMock.RequestMock; + let res: ExpressMock.ResponseMock; + let mocks: ServerVariablesMock; + let vars: ServerVariables; + + beforeEach(function () { + req = ExpressMock.RequestMock(); + req.app = {}; + req.originalUrl = "/api/xxxx"; + + const s = ServerVariablesMockBuilder.build(); + mocks = s.mocks; + vars = s.variables; + + req.session = { + auth: { + userid: "user", + authentication_level: Level.ONE_FACTOR, + identity_check: { + challenge: "u2f-register", + userid: "user" + } + } + }; + req.headers = {}; + req.headers.host = "localhost"; + + const options = { + inMemoryOnly: true + }; + + res = ExpressMock.ResponseMock(); + res.send = sinon.spy(); + res.json = sinon.spy(); + res.status = sinon.spy(); + }); + + it("should return status code 204", function () { + const expectedStatus = { + keyHandle: "keyHandle", + publicKey: "pbk", + certificate: "cert" + }; + mocks.u2f.checkSignatureStub.returns(expectedStatus); + + mocks.userDataStore.retrieveU2FRegistrationStub.returns(BluebirdPromise.resolve({ + registration: { + publicKey: "PUBKEY" + } + })); + + req.session.auth.sign_request = { + appId: "app", + challenge: "challenge", + keyHandle: "key", + version: "U2F_V2" + }; + return U2FSignPost.default(vars)(req as any, res as any) + .then(function () { + Assert.equal(req.session.auth.authentication_level, Level.TWO_FACTOR); + }); + }); + + it("should return unauthorized error on registration request internal error", function () { + mocks.userDataStore.retrieveU2FRegistrationStub.returns(BluebirdPromise.resolve({ + registration: { + publicKey: "PUBKEY" + } + })); + mocks.u2f.checkSignatureStub.returns({ errorCode: 500 }); + + req.session.auth.sign_request = { + appId: "app", + challenge: "challenge", + keyHandle: "key", + version: "U2F_V2" + }; + return U2FSignPost.default(vars)(req as any, res as any) + .then(function () { + Assert.equal(res.status.getCall(0).args[0], 200); + Assert.deepEqual(res.send.getCall(0).args[0], + { error: "Operation failed." }); + }); + }); +}); + diff --git a/themes/black/server/src/lib/routes/secondfactor/u2f/sign/post.ts b/themes/black/server/src/lib/routes/secondfactor/u2f/sign/post.ts new file mode 100644 index 00000000..7ee711c2 --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/u2f/sign/post.ts @@ -0,0 +1,57 @@ + +import objectPath = require("object-path"); +import u2f_common = require("../U2FCommon"); +import BluebirdPromise = require("bluebird"); +import express = require("express"); +import { UserDataStore } from "../../../../storage/UserDataStore"; +import { U2FRegistrationDocument } from "../../../../storage/U2FRegistrationDocument"; +import { Winston } from "../../../../../../types/Dependencies"; +import U2f = require("u2f"); +import exceptions = require("../../../../Exceptions"); +import redirect from "../../redirect"; +import ErrorReplies = require("../../../../ErrorReplies"); +import { ServerVariables } from "../../../../ServerVariables"; +import { AuthenticationSessionHandler } from "../../../../AuthenticationSessionHandler"; +import UserMessages = require("../../../../../../../shared/UserMessages"); +import { AuthenticationSession } from "../../../../../../types/AuthenticationSession"; +import { Level } from "../../../../authentication/Level"; + +export default function (vars: ServerVariables) { + function handler(req: express.Request, res: express.Response): BluebirdPromise { + let authSession: AuthenticationSession; + const appId = u2f_common.extract_app_id(req); + + return new BluebirdPromise(function (resolve, reject) { + authSession = AuthenticationSessionHandler.get(req, vars.logger); + if (!authSession.sign_request) { + const err = new Error("No sign request"); + ErrorReplies.replyWithError401(req, res, vars.logger)(err); + return reject(err); + } + resolve(); + }) + .then(function () { + const userid = authSession.userid; + return vars.userDataStore.retrieveU2FRegistration(userid, appId); + }) + .then(function (doc: U2FRegistrationDocument): BluebirdPromise { + const signRequest = authSession.sign_request; + const signData: U2f.SignatureData = req.body; + vars.logger.info(req, "Finish authentication"); + return BluebirdPromise.resolve(vars.u2f.checkSignature(signRequest, signData, doc.registration.publicKey)); + }) + .then(function (result: U2f.SignatureResult | U2f.Error): BluebirdPromise { + if (objectPath.has(result, "errorCode")) + return BluebirdPromise.reject(new Error("Error while signing")); + vars.logger.info(req, "Successful authentication"); + authSession.authentication_level = Level.TWO_FACTOR; + redirect(vars)(req, res); + return BluebirdPromise.resolve(); + }) + .catch(ErrorReplies.replyWithError200(req, res, vars.logger, + UserMessages.OPERATION_FAILED)); + } + + return handler; +} + diff --git a/themes/black/server/src/lib/routes/secondfactor/u2f/sign_request/get.spec.ts b/themes/black/server/src/lib/routes/secondfactor/u2f/sign_request/get.spec.ts new file mode 100644 index 00000000..dd52b27e --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/u2f/sign_request/get.spec.ts @@ -0,0 +1,68 @@ + +import sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); +import assert = require("assert"); +import U2FSignRequestGet = require("./get"); +import ExpressMock = require("../../../../stubs/express.spec"); +import { Request } from "u2f"; +import { ServerVariablesMock, ServerVariablesMockBuilder } from "../../../../ServerVariablesMockBuilder.spec"; +import { ServerVariables } from "../../../../ServerVariables"; + +import { SignMessage } from "../../../../../../../shared/SignMessage"; + +describe("routes/secondfactor/u2f/sign_request/get", function () { + let req: ExpressMock.RequestMock; + let res: ExpressMock.ResponseMock; + let mocks: ServerVariablesMock; + let vars: ServerVariables; + + beforeEach(function () { + req = ExpressMock.RequestMock(); + req.originalUrl = "/api/xxxx"; + req.app = {}; + req.session = { + auth: { + userid: "user", + first_factor: true, + second_factor: false, + identity_check: { + challenge: "u2f-register", + userid: "user" + } + } + }; + req.headers = {}; + req.headers.host = "localhost"; + + const s = ServerVariablesMockBuilder.build(); + mocks = s.mocks; + vars = s.variables; + + res = ExpressMock.ResponseMock(); + res.send = sinon.spy(); + res.json = sinon.spy(); + res.status = sinon.spy(); + }); + + it("should send back the sign request and save it in the session", function () { + const expectedRequest: Request = { + version: "U2F_V2", + appId: 'app', + challenge: 'challenge!' + }; + mocks.u2f.requestStub.returns(expectedRequest); + mocks.userDataStore.retrieveU2FRegistrationStub + .returns(BluebirdPromise.resolve({ + registration: { + keyHandle: "KeyHandle" + } + })); + + return U2FSignRequestGet.default(vars)(req as any, res as any) + .then(() => { + assert.deepEqual(expectedRequest, req.session.auth.sign_request); + assert.deepEqual(expectedRequest, res.json.getCall(0).args[0]); + }); + }); +}); + diff --git a/themes/black/server/src/lib/routes/secondfactor/u2f/sign_request/get.ts b/themes/black/server/src/lib/routes/secondfactor/u2f/sign_request/get.ts new file mode 100644 index 00000000..9e93dde0 --- /dev/null +++ b/themes/black/server/src/lib/routes/secondfactor/u2f/sign_request/get.ts @@ -0,0 +1,42 @@ + +import u2f_common = require("../../../secondfactor/u2f/U2FCommon"); +import BluebirdPromise = require("bluebird"); +import express = require("express"); +import { U2FRegistrationDocument } from "../../../../storage/U2FRegistrationDocument"; +import exceptions = require("../../../../Exceptions"); +import ErrorReplies = require("../../../../ErrorReplies"); +import { AuthenticationSessionHandler } from "../../../../AuthenticationSessionHandler"; +import UserMessages = require("../../../../../../../shared/UserMessages"); +import { ServerVariables } from "../../../../ServerVariables"; +import { AuthenticationSession } from "../../../../../../types/AuthenticationSession"; + +export default function (vars: ServerVariables) { + function handler(req: express.Request, res: express.Response): BluebirdPromise { + let authSession: AuthenticationSession; + const appId = u2f_common.extract_app_id(req); + + return new BluebirdPromise(function (resolve, reject) { + authSession = AuthenticationSessionHandler.get(req, vars.logger); + resolve(); + }) + .then(function () { + return vars.userDataStore.retrieveU2FRegistration(authSession.userid, appId); + }) + .then(function (doc: U2FRegistrationDocument): BluebirdPromise { + if (!doc) + return BluebirdPromise.reject(new exceptions.AccessDeniedError("No U2F registration document found.")); + + const appId: string = u2f_common.extract_app_id(req); + vars.logger.info(req, "Start authentication of app '%s'", appId); + vars.logger.debug(req, "AppId = %s, keyHandle = %s", appId, JSON.stringify(doc.registration.keyHandle)); + + const request = vars.u2f.request(appId, doc.registration.keyHandle); + authSession.sign_request = request; + res.json(request); + return BluebirdPromise.resolve(); + }) + .catch(ErrorReplies.replyWithError200(req, res, vars.logger, + UserMessages.OPERATION_FAILED)); + } + return handler; +} diff --git a/themes/black/server/src/lib/routes/verify/access_control.ts b/themes/black/server/src/lib/routes/verify/access_control.ts new file mode 100644 index 00000000..136239ae --- /dev/null +++ b/themes/black/server/src/lib/routes/verify/access_control.ts @@ -0,0 +1,51 @@ +import Express = require("express"); +import BluebirdPromise = require("bluebird"); +import Util = require("util"); + +import Exceptions = require("../../Exceptions"); + +import { Level as AuthorizationLevel } from "../../authorization/Level"; +import { Level as AuthenticationLevel } from "../../authentication/Level"; +import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler"; +import { ServerVariables } from "../../ServerVariables"; + +function isAuthorized( + authorization: AuthorizationLevel, + authentication: AuthenticationLevel): boolean { + + if (authorization == AuthorizationLevel.BYPASS) { + return true; + } else if (authorization == AuthorizationLevel.ONE_FACTOR && + authentication >= AuthenticationLevel.ONE_FACTOR) { + return true; + } else if (authorization == AuthorizationLevel.TWO_FACTOR && + authentication >= AuthenticationLevel.TWO_FACTOR) { + return true; + } + return false; +} + +export default function ( + req: Express.Request, + vars: ServerVariables, + domain: string, resource: string, + user: string, groups: string[], + authenticationLevel: AuthenticationLevel) { + + return new BluebirdPromise(function (resolve, reject) { + const authorizationLevel = vars.authorizer + .authorization({domain, resource}, {user, groups}); + + if (!isAuthorized(authorizationLevel, authenticationLevel)) { + if (authorizationLevel == AuthorizationLevel.DENY) { + reject(new Exceptions.NotAuthorizedError( + Util.format("User %s is not authorized to access %s%s", user, domain, resource))); + return; + } + reject(new Exceptions.NotAuthenticatedError(Util.format( + "User '%s' is not sufficiently authorized to access %s%s.", user, domain, resource))); + return; + } + resolve(); + }); +} \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/verify/get.spec.ts b/themes/black/server/src/lib/routes/verify/get.spec.ts new file mode 100644 index 00000000..67cf19fb --- /dev/null +++ b/themes/black/server/src/lib/routes/verify/get.spec.ts @@ -0,0 +1,320 @@ + +import Assert = require("assert"); +import BluebirdPromise = require("bluebird"); +import Express = require("express"); +import Sinon = require("sinon"); +import winston = require("winston"); + +import VerifyGet = require("./get"); +import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler"; +import { AuthenticationSession } from "../../../../types/AuthenticationSession"; +import ExpressMock = require("../../stubs/express.spec"); +import { ServerVariables } from "../../ServerVariables"; +import { ServerVariablesMockBuilder, ServerVariablesMock } from "../../ServerVariablesMockBuilder.spec"; +import { Level } from "../../authentication/Level"; +import { Level as AuthorizationLevel } from "../../authorization/Level"; + +describe("routes/verify/get", function () { + let req: ExpressMock.RequestMock; + let res: ExpressMock.ResponseMock; + let mocks: ServerVariablesMock; + let vars: ServerVariables; + let authSession: AuthenticationSession; + + beforeEach(function () { + req = ExpressMock.RequestMock(); + res = ExpressMock.ResponseMock(); + req.originalUrl = "/api/xxxx"; + req.query = { + redirect: "undefined" + }; + AuthenticationSessionHandler.reset(req as any); + req.headers["x-original-url"] = "https://secret.example.com/"; + const s = ServerVariablesMockBuilder.build(false); + mocks = s.mocks; + vars = s.variables; + authSession = AuthenticationSessionHandler.get(req as any, vars.logger); + }); + + describe("with session cookie", function () { + it("should be already authenticated", function () { + mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); + authSession.authentication_level = Level.TWO_FACTOR; + authSession.userid = "myuser"; + authSession.groups = ["mygroup", "othergroup"]; + return VerifyGet.default(vars)(req as Express.Request, res as any) + .then(function () { + Sinon.assert.calledWithExactly(res.setHeader, "Remote-User", "myuser"); + Sinon.assert.calledWithExactly(res.setHeader, "Remote-Groups", "mygroup,othergroup"); + Assert.equal(204, res.status.getCall(0).args[0]); + }); + }); + + function test_session(_authSession: AuthenticationSession, status_code: number) { + return VerifyGet.default(vars)(req as Express.Request, res as any) + .then(function () { + Assert.equal(status_code, res.status.getCall(0).args[0]); + }); + } + + function test_non_authenticated_401(authSession: AuthenticationSession) { + return test_session(authSession, 401); + } + + function test_unauthorized_403(authSession: AuthenticationSession) { + return test_session(authSession, 403); + } + + function test_authorized(authSession: AuthenticationSession) { + return test_session(authSession, 204); + } + + describe("given user tries to access a 2-factor endpoint", function () { + before(function () { + mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); + }); + + describe("given different cases of session", function () { + it("should not be authenticated when second factor is missing", function () { + return test_non_authenticated_401({ + keep_me_logged_in: false, + userid: "user", + authentication_level: Level.ONE_FACTOR, + email: undefined, + groups: [], + last_activity_datetime: new Date().getTime() + }); + }); + + it("should not be authenticated when userid is missing", function () { + return test_non_authenticated_401({ + keep_me_logged_in: false, + userid: undefined, + authentication_level: Level.TWO_FACTOR, + email: undefined, + groups: [], + last_activity_datetime: new Date().getTime() + }); + }); + + it("should not be authenticated when level is insufficient", function () { + return test_non_authenticated_401({ + keep_me_logged_in: false, + userid: "user", + authentication_level: Level.NOT_AUTHENTICATED, + email: undefined, + groups: [], + last_activity_datetime: new Date().getTime() + }); + }); + + it("should not be authenticated when session has not be initiated", function () { + return test_non_authenticated_401(undefined); + }); + + it("should not be authenticated when domain is not allowed for user", function () { + authSession.authentication_level = Level.TWO_FACTOR; + authSession.userid = "myuser"; + req.headers["x-original-url"] = "https://test.example.com/"; + mocks.authorizer.authorizationMock.returns(AuthorizationLevel.DENY); + + return test_unauthorized_403({ + keep_me_logged_in: false, + authentication_level: Level.TWO_FACTOR, + userid: "user", + groups: ["group1", "group2"], + email: undefined, + last_activity_datetime: new Date().getTime() + }); + }); + }); + }); + + describe("given user tries to access a single factor endpoint", function () { + beforeEach(function () { + req.headers["x-original-url"] = "https://redirect.url/"; + }); + + it("should be authenticated when first factor is validated", function () { + mocks.authorizer.authorizationMock.returns(AuthorizationLevel.ONE_FACTOR); + authSession.authentication_level = Level.ONE_FACTOR; + authSession.userid = "user1"; + return VerifyGet.default(vars)(req as Express.Request, res as any) + .then(function () { + Assert(res.status.calledWith(204)); + Assert(res.send.calledOnce); + }); + }); + + it("should be rejected with 401 when not authenticated", function () { + mocks.authorizer.authorizationMock.returns(AuthorizationLevel.ONE_FACTOR); + authSession.authentication_level = Level.NOT_AUTHENTICATED; + return VerifyGet.default(vars)(req as Express.Request, res as any) + .then(function () { + Assert(res.status.calledWith(401)); + }); + }); + }); + + describe("inactivity period", function () { + it("should update last inactivity period on requests on /api/verify", function () { + mocks.config.session.inactivity = 200000; + mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); + const currentTime = new Date().getTime() - 1000; + AuthenticationSessionHandler.reset(req as any); + authSession.authentication_level = Level.TWO_FACTOR; + authSession.userid = "myuser"; + authSession.groups = ["mygroup", "othergroup"]; + authSession.last_activity_datetime = currentTime; + return VerifyGet.default(vars)(req as Express.Request, res as any) + .then(function () { + return AuthenticationSessionHandler.get(req as any, vars.logger); + }) + .then(function (authSession) { + Assert(authSession.last_activity_datetime > currentTime); + }); + }); + + it("should reset session when max inactivity period has been reached", function () { + mocks.config.session.inactivity = 1; + mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); + const currentTime = new Date().getTime() - 1000; + AuthenticationSessionHandler.reset(req as any); + authSession.authentication_level = Level.TWO_FACTOR; + authSession.userid = "myuser"; + authSession.groups = ["mygroup", "othergroup"]; + authSession.last_activity_datetime = currentTime; + return VerifyGet.default(vars)(req as Express.Request, res as any) + .then(function () { + return AuthenticationSessionHandler.get(req as any, vars.logger); + }) + .then(function (authSession) { + Assert.equal(authSession.authentication_level, Level.NOT_AUTHENTICATED); + Assert.equal(authSession.userid, undefined); + }); + }); + }); + }); + + describe("response type 401 | 302", function() { + it("should return error code 401", function() { + mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); + mocks.config.access_control.default_policy = "one_factor"; + mocks.usersDatabase.checkUserPasswordStub.rejects(new Error( + "Invalid credentials")); + req.headers["proxy-authorization"] = "Basic am9objpwYXNzd29yZA=="; + + return VerifyGet.default(vars)(req as Express.Request, res as any) + .then(function () { + Assert(res.status.calledWithExactly(401)); + }); + }); + + it("should redirect to provided redirection url", function() { + const REDIRECT_URL = "http://redirection_url.com"; + mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); + mocks.config.access_control.default_policy = "one_factor"; + mocks.usersDatabase.checkUserPasswordStub.rejects(new Error( + "Invalid credentials")); + req.headers["proxy-authorization"] = "Basic am9objpwYXNzd29yZA=="; + req.query["rd"] = REDIRECT_URL; + + return VerifyGet.default(vars)(req as Express.Request, res as any) + .then(function () { + Assert(res.redirect.calledWithExactly(REDIRECT_URL)); + }); + }); + }); + + describe("with basic auth", function () { + it("should authenticate correctly", function () { + mocks.authorizer.authorizationMock.returns(AuthorizationLevel.ONE_FACTOR); + mocks.config.access_control.default_policy = "one_factor"; + mocks.usersDatabase.checkUserPasswordStub.returns({ + groups: ["mygroup", "othergroup"], + }); + req.headers["proxy-authorization"] = "Basic am9objpwYXNzd29yZA=="; + + return VerifyGet.default(vars)(req as Express.Request, res as any) + .then(function () { + Sinon.assert.calledWithExactly(res.setHeader, "Remote-User", "john"); + Sinon.assert.calledWithExactly(res.setHeader, "Remote-Groups", "mygroup,othergroup"); + Assert.equal(204, res.status.getCall(0).args[0]); + }); + }); + + it("should fail when endpoint is protected by two factors", function () { + mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); + mocks.config.access_control.default_policy = "one_factor"; + mocks.config.access_control.rules = [{ + domain: "secret.example.com", + policy: "two_factor" + }]; + mocks.usersDatabase.checkUserPasswordStub.resolves({ + groups: ["mygroup", "othergroup"], + }); + req.headers["proxy-authorization"] = "Basic am9objpwYXNzd29yZA=="; + + return VerifyGet.default(vars)(req as Express.Request, res as any) + .then(function () { + Assert(res.status.calledWithExactly(401)); + }); + }); + + it("should fail when base64 token is not valid", function () { + mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); + mocks.config.access_control.default_policy = "one_factor"; + mocks.usersDatabase.checkUserPasswordStub.resolves({ + groups: ["mygroup", "othergroup"], + }); + req.headers["proxy-authorization"] = "Basic i_m*not_a_base64*token"; + + return VerifyGet.default(vars)(req as Express.Request, res as any) + .then(function () { + Assert(res.status.calledWithExactly(401)); + }); + }); + + it("should fail when base64 token has not format user:psswd", function () { + mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); + mocks.config.access_control.default_policy = "one_factor"; + mocks.usersDatabase.checkUserPasswordStub.resolves({ + groups: ["mygroup", "othergroup"], + }); + req.headers["proxy-authorization"] = "Basic am9objpwYXNzOmJhZA=="; + + return VerifyGet.default(vars)(req as Express.Request, res as any) + .then(function () { + Assert(res.status.calledWithExactly(401)); + }); + }); + + it("should fail when bad user password is provided", function () { + mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); + mocks.config.access_control.default_policy = "one_factor"; + mocks.usersDatabase.checkUserPasswordStub.rejects(new Error( + "Invalid credentials")); + req.headers["proxy-authorization"] = "Basic am9objpwYXNzd29yZA=="; + + return VerifyGet.default(vars)(req as Express.Request, res as any) + .then(function () { + Assert(res.status.calledWithExactly(401)); + }); + }); + + it("should fail when resource is restricted", function () { + mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); + mocks.config.access_control.default_policy = "one_factor"; + mocks.usersDatabase.checkUserPasswordStub.resolves({ + groups: ["mygroup", "othergroup"], + }); + req.headers["proxy-authorization"] = "Basic am9objpwYXNzd29yZA=="; + + return VerifyGet.default(vars)(req as Express.Request, res as any) + .then(function () { + Assert(res.status.calledWithExactly(401)); + }); + }); + }); +}); + diff --git a/themes/black/server/src/lib/routes/verify/get.ts b/themes/black/server/src/lib/routes/verify/get.ts new file mode 100644 index 00000000..f7386169 --- /dev/null +++ b/themes/black/server/src/lib/routes/verify/get.ts @@ -0,0 +1,91 @@ +import BluebirdPromise = require("bluebird"); +import Express = require("express"); +import Exceptions = require("../../Exceptions"); +import ErrorReplies = require("../../ErrorReplies"); +import { ServerVariables } from "../../ServerVariables"; +import GetWithSessionCookieMethod from "./get_session_cookie"; +import GetWithBasicAuthMethod from "./get_basic_auth"; +import Constants = require("../../../../../shared/constants"); +import ObjectPath = require("object-path"); + +import { AuthenticationSessionHandler } + from "../../AuthenticationSessionHandler"; +import { AuthenticationSession } + from "../../../../types/AuthenticationSession"; + +const REMOTE_USER = "Remote-User"; +const REMOTE_GROUPS = "Remote-Groups"; + + +function verifyWithSelectedMethod(req: Express.Request, res: Express.Response, + vars: ServerVariables, authSession: AuthenticationSession) + : () => BluebirdPromise<{ username: string, groups: string[] }> { + return function () { + const authorization: string = "" + req.headers["proxy-authorization"]; + if (authorization && authorization.startsWith("Basic ")) + return GetWithBasicAuthMethod(req, res, vars, authorization); + + return GetWithSessionCookieMethod(req, res, vars, authSession); + }; +} + +function setRedirectHeader(req: Express.Request, res: Express.Response) { + return function () { + const originalUrl = ObjectPath.get( + req, "headers.x-original-url"); + res.set("Redirect", originalUrl); + return BluebirdPromise.resolve(); + }; +} + +function setUserAndGroupsHeaders(res: Express.Response) { + return function (u: { username: string, groups: string[] }) { + res.setHeader(REMOTE_USER, u.username); + res.setHeader(REMOTE_GROUPS, u.groups.join(",")); + return BluebirdPromise.resolve(); + }; +} + +function replyWith200(res: Express.Response) { + return function () { + res.status(204); + res.send(); + }; +} + +function getRedirectParam(req: Express.Request) { + return req.query[Constants.REDIRECT_QUERY_PARAM] != "undefined" + ? req.query[Constants.REDIRECT_QUERY_PARAM] + : undefined; +} + +export default function (vars: ServerVariables) { + return function (req: Express.Request, res: Express.Response) + : BluebirdPromise { + let authSession: AuthenticationSession; + return new BluebirdPromise(function (resolve, reject) { + authSession = AuthenticationSessionHandler.get(req, vars.logger); + resolve(); + }) + .then(setRedirectHeader(req, res)) + .then(verifyWithSelectedMethod(req, res, vars, authSession)) + .then(setUserAndGroupsHeaders(res)) + .then(replyWith200(res)) + // The user is authenticated but has restricted access -> 403 + .catch(Exceptions.NotAuthorizedError, + ErrorReplies.replyWithError403(req, res, vars.logger)) + .catch(Exceptions.NotAuthenticatedError, + ErrorReplies.replyWithError401(req, res, vars.logger)) + // The user is not yet authenticated -> 401 + .catch((err) => { + const redirectUrl = getRedirectParam(req); + if (redirectUrl) { + ErrorReplies.redirectTo(redirectUrl, req, res, vars.logger)(err); + } + else { + ErrorReplies.replyWithError401(req, res, vars.logger)(err); + } + }); + }; +} + diff --git a/themes/black/server/src/lib/routes/verify/get_basic_auth.ts b/themes/black/server/src/lib/routes/verify/get_basic_auth.ts new file mode 100644 index 00000000..af23c76c --- /dev/null +++ b/themes/black/server/src/lib/routes/verify/get_basic_auth.ts @@ -0,0 +1,55 @@ +import Express = require("express"); +import BluebirdPromise = require("bluebird"); +import ObjectPath = require("object-path"); +import { ServerVariables } from "../../ServerVariables"; +import { AuthenticationSession } + from "../../../../types/AuthenticationSession"; +import AccessControl from "./access_control"; +import { URLDecomposer } from "../../utils/URLDecomposer"; +import { Level } from "../../authentication/Level"; + +export default function (req: Express.Request, res: Express.Response, + vars: ServerVariables, authorizationHeader: string) + : BluebirdPromise<{ username: string, groups: string[] }> { + let username: string; + const uri = ObjectPath.get(req, "headers.x-original-url"); + const urlDecomposition = URLDecomposer.fromUrl(uri); + + return BluebirdPromise.resolve() + .then(() => { + const base64Re = new RegExp("^Basic ((?:[A-Za-z0-9+/]{4})*" + + "(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?)$"); + const isTokenValidBase64 = base64Re.test(authorizationHeader); + + if (!isTokenValidBase64) { + return BluebirdPromise.reject(new Error("No valid base64 token found in the header")); + } + + const tokenMatches = authorizationHeader.match(base64Re); + const base64Token = tokenMatches[1]; + const decodedToken = Buffer.from(base64Token, "base64").toString(); + const splittedToken = decodedToken.split(":"); + + if (splittedToken.length != 2) { + return BluebirdPromise.reject(new Error( + "The authorization token is invalid. Expecting 'userid:password'")); + } + + username = splittedToken[0]; + const password = splittedToken[1]; + return vars.usersDatabase.checkUserPassword(username, password); + }) + .then(function (groupsAndEmails) { + return AccessControl(req, vars, urlDecomposition.domain, urlDecomposition.path, + username, groupsAndEmails.groups, Level.ONE_FACTOR) + .then(() => BluebirdPromise.resolve({ + username: username, + groups: groupsAndEmails.groups + })); + }) + .catch(function (err: Error) { + return BluebirdPromise.reject( + new Error("Unable to authenticate the user with basic auth. Cause: " + + err.message)); + }); +} \ No newline at end of file diff --git a/themes/black/server/src/lib/routes/verify/get_session_cookie.ts b/themes/black/server/src/lib/routes/verify/get_session_cookie.ts new file mode 100644 index 00000000..07034481 --- /dev/null +++ b/themes/black/server/src/lib/routes/verify/get_session_cookie.ts @@ -0,0 +1,78 @@ +import Express = require("express"); +import BluebirdPromise = require("bluebird"); +import Util = require("util"); +import ObjectPath = require("object-path"); + +import Exceptions = require("../../Exceptions"); +import { Configuration } from "../../configuration/schema/Configuration"; +import { ServerVariables } from "../../ServerVariables"; +import { IRequestLogger } from "../../logging/IRequestLogger"; +import { AuthenticationSession } + from "../../../../types/AuthenticationSession"; +import { AuthenticationSessionHandler } + from "../../AuthenticationSessionHandler"; +import AccessControl from "./access_control"; +import { URLDecomposer } from "../../utils/URLDecomposer"; + +function verify_inactivity(req: Express.Request, + authSession: AuthenticationSession, + configuration: Configuration, logger: IRequestLogger) + : BluebirdPromise { + + // If inactivity is not specified, then inactivity timeout does not apply + if (!configuration.session.inactivity || authSession.keep_me_logged_in) { + return BluebirdPromise.resolve(); + } + + const lastActivityTime = authSession.last_activity_datetime; + const currentTime = new Date().getTime(); + authSession.last_activity_datetime = currentTime; + + const inactivityPeriodMs = currentTime - lastActivityTime; + logger.debug(req, "Inactivity period was %s s and max period was %s.", + inactivityPeriodMs / 1000, configuration.session.inactivity / 1000); + if (inactivityPeriodMs < configuration.session.inactivity) { + return BluebirdPromise.resolve(); + } + + logger.debug(req, "Session has been reset after too long inactivity period."); + AuthenticationSessionHandler.reset(req); + return BluebirdPromise.reject(new Error("Inactivity period exceeded.")); +} + +export default function (req: Express.Request, res: Express.Response, + vars: ServerVariables, authSession: AuthenticationSession) + : BluebirdPromise<{ username: string, groups: string[] }> { + + return BluebirdPromise.resolve() + .then(() => { + const username = authSession.userid; + const groups = authSession.groups; + + if (!authSession.userid) { + return BluebirdPromise.reject(new Exceptions.AccessDeniedError( + "userid is missing")); + } + + const originalUrl = ObjectPath.get( + req, "headers.x-original-url"); + const originalUri = + ObjectPath.get(req, "headers.x-original-uri"); + + const d = URLDecomposer.fromUrl(originalUrl); + vars.logger.debug(req, "domain=%s, path=%s, user=%s, groups=%s", d.domain, + d.path, username, groups.join(",")); + return AccessControl(req, vars, d.domain, d.path, username, groups, + authSession.authentication_level); + }) + .then(() => { + return verify_inactivity(req, authSession, + vars.config, vars.logger); + }) + .then(() => { + return BluebirdPromise.resolve({ + username: authSession.userid, + groups: authSession.groups + }); + }); +} \ No newline at end of file diff --git a/themes/black/server/src/lib/storage/AuthenticationTraceDocument.d.ts b/themes/black/server/src/lib/storage/AuthenticationTraceDocument.d.ts new file mode 100644 index 00000000..69818c05 --- /dev/null +++ b/themes/black/server/src/lib/storage/AuthenticationTraceDocument.d.ts @@ -0,0 +1,6 @@ + +export interface AuthenticationTraceDocument { + userId: string; + date: Date; + isAuthenticationSuccessful: boolean; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/storage/CollectionFactoryFactory.ts b/themes/black/server/src/lib/storage/CollectionFactoryFactory.ts new file mode 100644 index 00000000..92b29abf --- /dev/null +++ b/themes/black/server/src/lib/storage/CollectionFactoryFactory.ts @@ -0,0 +1,15 @@ +import { ICollectionFactory } from "./ICollectionFactory"; +import { NedbCollectionFactory } from "./nedb/NedbCollectionFactory"; +import { MongoCollectionFactory } from "./mongo/MongoCollectionFactory"; +import { IMongoClient } from "../connectors/mongo/IMongoClient"; + + +export class CollectionFactoryFactory { + static createNedb(options: Nedb.DataStoreOptions): ICollectionFactory { + return new NedbCollectionFactory(options); + } + + static createMongo(client: IMongoClient): ICollectionFactory { + return new MongoCollectionFactory(client); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/storage/CollectionFactoryStub.spec.ts b/themes/black/server/src/lib/storage/CollectionFactoryStub.spec.ts new file mode 100644 index 00000000..17f8bb02 --- /dev/null +++ b/themes/black/server/src/lib/storage/CollectionFactoryStub.spec.ts @@ -0,0 +1,16 @@ +import BluebirdPromise = require("bluebird"); +import Sinon = require("sinon"); +import { ICollection } from "./ICollection"; +import { ICollectionFactory } from "./ICollectionFactory"; + +export class CollectionFactoryStub implements ICollectionFactory { + buildStub: Sinon.SinonStub; + + constructor() { + this.buildStub = Sinon.stub(); + } + + build(collectionName: string): ICollection { + return this.buildStub(collectionName); + } +} diff --git a/themes/black/server/src/lib/storage/CollectionStub.spec.ts b/themes/black/server/src/lib/storage/CollectionStub.spec.ts new file mode 100644 index 00000000..42895d67 --- /dev/null +++ b/themes/black/server/src/lib/storage/CollectionStub.spec.ts @@ -0,0 +1,39 @@ +import BluebirdPromise = require("bluebird"); +import Sinon = require("sinon"); +import { ICollection } from "./ICollection"; + +export class CollectionStub implements ICollection { + findStub: Sinon.SinonStub; + findOneStub: Sinon.SinonStub; + updateStub: Sinon.SinonStub; + removeStub: Sinon.SinonStub; + insertStub: Sinon.SinonStub; + + constructor() { + this.findStub = Sinon.stub(); + this.findOneStub = Sinon.stub(); + this.updateStub = Sinon.stub(); + this.removeStub = Sinon.stub(); + this.insertStub = Sinon.stub(); + } + + find(filter: any, sortKeys: any, count: number): BluebirdPromise { + return this.findStub(filter, sortKeys, count); + } + + findOne(filter: any): BluebirdPromise { + return this.findOneStub(filter); + } + + update(filter: any, document: any, options: any): BluebirdPromise { + return this.updateStub(filter, document, options); + } + + remove(filter: any): BluebirdPromise { + return this.removeStub(filter); + } + + insert(document: any): BluebirdPromise { + return this.insertStub(document); + } +} diff --git a/themes/black/server/src/lib/storage/ICollection.d.ts b/themes/black/server/src/lib/storage/ICollection.d.ts new file mode 100644 index 00000000..caa6c2a8 --- /dev/null +++ b/themes/black/server/src/lib/storage/ICollection.d.ts @@ -0,0 +1,11 @@ +/* istanbul ignore next */ +import BluebirdPromise = require("bluebird"); + +/* istanbul ignore next */ +export interface ICollection { + find(query: any, sortKeys: any, count: number): BluebirdPromise; + findOne(query: any): BluebirdPromise; + update(query: any, updateQuery: any, options?: any): BluebirdPromise; + remove(query: any): BluebirdPromise; + insert(document: any): BluebirdPromise; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/storage/ICollectionFactory.d.ts b/themes/black/server/src/lib/storage/ICollectionFactory.d.ts new file mode 100644 index 00000000..39eb42c7 --- /dev/null +++ b/themes/black/server/src/lib/storage/ICollectionFactory.d.ts @@ -0,0 +1,6 @@ + +import { ICollection } from "./ICollection"; + +export interface ICollectionFactory { + build(collectionName: string): ICollection; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/storage/IUserDataStore.d.ts b/themes/black/server/src/lib/storage/IUserDataStore.d.ts new file mode 100644 index 00000000..81df482a --- /dev/null +++ b/themes/black/server/src/lib/storage/IUserDataStore.d.ts @@ -0,0 +1,21 @@ +import BluebirdPromise = require("bluebird"); +import { TOTPSecretDocument } from "./TOTPSecretDocument"; +import { U2FRegistrationDocument } from "./U2FRegistrationDocument"; +import { U2FRegistration } from "../../../types/U2FRegistration"; +import { TOTPSecret } from "../../../types/TOTPSecret"; +import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument"; +import { IdentityValidationDocument } from "./IdentityValidationDocument"; + +export interface IUserDataStore { + saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise; + retrieveU2FRegistration(userId: string, appId: string): BluebirdPromise; + + saveAuthenticationTrace(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise; + retrieveLatestAuthenticationTraces(userId: string, count: number): BluebirdPromise; + + produceIdentityValidationToken(userId: string, token: string, challenge: string, maxAge: number): BluebirdPromise; + consumeIdentityValidationToken(token: string, challenge: string): BluebirdPromise; + + saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise; + retrieveTOTPSecret(userId: string): BluebirdPromise; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/storage/IdentityValidationDocument.d.ts b/themes/black/server/src/lib/storage/IdentityValidationDocument.d.ts new file mode 100644 index 00000000..e7fd7b3f --- /dev/null +++ b/themes/black/server/src/lib/storage/IdentityValidationDocument.d.ts @@ -0,0 +1,7 @@ + +export interface IdentityValidationDocument { + userId: string; + token: string; + challenge: string; + maxDate: Date; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/storage/TOTPSecretDocument.d.ts b/themes/black/server/src/lib/storage/TOTPSecretDocument.d.ts new file mode 100644 index 00000000..a6c0bf9e --- /dev/null +++ b/themes/black/server/src/lib/storage/TOTPSecretDocument.d.ts @@ -0,0 +1,6 @@ +import { TOTPSecret } from "../../../types/TOTPSecret"; + +export interface TOTPSecretDocument { + userid: string; + secret: TOTPSecret; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/storage/U2FRegistrationDocument.d.ts b/themes/black/server/src/lib/storage/U2FRegistrationDocument.d.ts new file mode 100644 index 00000000..efec6cb1 --- /dev/null +++ b/themes/black/server/src/lib/storage/U2FRegistrationDocument.d.ts @@ -0,0 +1,8 @@ + +import { U2FRegistration } from "../../../types/U2FRegistration"; + +export interface U2FRegistrationDocument { + userId: string; + appId: string; + registration: U2FRegistration; +} \ No newline at end of file diff --git a/themes/black/server/src/lib/storage/UserDataStore.spec.ts b/themes/black/server/src/lib/storage/UserDataStore.spec.ts new file mode 100644 index 00000000..66fb8546 --- /dev/null +++ b/themes/black/server/src/lib/storage/UserDataStore.spec.ts @@ -0,0 +1,264 @@ + +import * as Assert from "assert"; +import * as Sinon from "sinon"; +import * as MockDate from "mockdate"; +import BluebirdPromise = require("bluebird"); + +import { UserDataStore } from "./UserDataStore"; +import { TOTPSecret } from "../../../types/TOTPSecret"; +import { U2FRegistration } from "../../../types/U2FRegistration"; +import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument"; +import { CollectionStub } from "./CollectionStub.spec"; +import { CollectionFactoryStub } from "./CollectionFactoryStub.spec"; + +describe("storage/UserDataStore", function () { + let factory: CollectionFactoryStub; + let collection: CollectionStub; + let userId: string; + let appId: string; + let totpSecret: TOTPSecret; + let u2fRegistration: U2FRegistration; + + beforeEach(function () { + factory = new CollectionFactoryStub(); + collection = new CollectionStub(); + + userId = "user"; + appId = "https://myappId"; + + totpSecret = { + ascii: "abc", + base32: "ABCDKZLEFZGREJK", + otpauth_url: "totp://test", + google_auth_qr: "dummy", + hex: "dummy", + qr_code_ascii: "dummy", + qr_code_base32: "dummy", + qr_code_hex: "dummy" + }; + + u2fRegistration = { + keyHandle: "KEY_HANDLE", + publicKey: "publickey" + }; + }); + + it("should correctly creates collections", function () { + new UserDataStore(factory); + + Assert.equal(4, factory.buildStub.callCount); + Assert(factory.buildStub.calledWith("authentication_traces")); + Assert(factory.buildStub.calledWith("identity_validation_tokens")); + Assert(factory.buildStub.calledWith("u2f_registrations")); + Assert(factory.buildStub.calledWith("totp_secrets")); + }); + + describe("TOTP secrets collection", function () { + it("should save a totp secret", function () { + factory.buildStub.returns(collection); + collection.updateStub.returns(BluebirdPromise.resolve()); + + const dataStore = new UserDataStore(factory); + + return dataStore.saveTOTPSecret(userId, totpSecret) + .then(function (doc) { + Assert(collection.updateStub.calledOnce); + Assert(collection.updateStub.calledWith({ userId: userId }, { + userId: userId, + secret: totpSecret + }, { upsert: true })); + return BluebirdPromise.resolve(); + }); + }); + + it("should retrieve a totp secret", function () { + factory.buildStub.returns(collection); + collection.findOneStub.withArgs().returns(BluebirdPromise.resolve()); + + const dataStore = new UserDataStore(factory); + + return dataStore.retrieveTOTPSecret(userId) + .then(function (doc) { + Assert(collection.findOneStub.calledOnce); + Assert(collection.findOneStub.calledWith({ userId: userId })); + return BluebirdPromise.resolve(); + }); + }); + }); + + describe("U2F secrets collection", function () { + it("should save a U2F secret", function () { + factory.buildStub.returns(collection); + collection.updateStub.returns(BluebirdPromise.resolve()); + + const dataStore = new UserDataStore(factory); + + return dataStore.saveU2FRegistration(userId, appId, u2fRegistration) + .then(function (doc) { + Assert(collection.updateStub.calledOnce); + Assert(collection.updateStub.calledWith({ + userId: userId, + appId: appId + }, { + userId: userId, + appId: appId, + registration: u2fRegistration + }, { upsert: true })); + return BluebirdPromise.resolve(); + }); + }); + + it("should retrieve a U2F secret", function () { + factory.buildStub.returns(collection); + collection.findOneStub.withArgs().returns(BluebirdPromise.resolve()); + + const dataStore = new UserDataStore(factory); + + return dataStore.retrieveU2FRegistration(userId, appId) + .then(function (doc) { + Assert(collection.findOneStub.calledOnce); + Assert(collection.findOneStub.calledWith({ + userId: userId, + appId: appId + })); + return BluebirdPromise.resolve(); + }); + }); + }); + + + describe("Regulator traces collection", function () { + it("should save a trace", function () { + factory.buildStub.returns(collection); + collection.insertStub.returns(BluebirdPromise.resolve()); + + const dataStore = new UserDataStore(factory); + + return dataStore.saveAuthenticationTrace(userId, true) + .then(function (doc) { + Assert(collection.insertStub.calledOnce); + Assert(collection.insertStub.calledWith({ + userId: userId, + date: Sinon.match.date, + isAuthenticationSuccessful: true + })); + return BluebirdPromise.resolve(); + }); + }); + + function should_retrieve_latest_authentication_traces(count: number) { + factory.buildStub.returns(collection); + collection.findStub.withArgs().returns(BluebirdPromise.resolve()); + + const dataStore = new UserDataStore(factory); + + return dataStore.retrieveLatestAuthenticationTraces(userId, count) + .then(function (doc: AuthenticationTraceDocument[]) { + Assert(collection.findStub.calledOnce); + Assert(collection.findStub.calledWith({ + userId: userId, + }, { date: -1 }, count)); + return BluebirdPromise.resolve(); + }); + } + + it("should retrieve 3 latest failed authentication traces", function () { + should_retrieve_latest_authentication_traces(3); + }); + }); + + + describe("Identity validation collection", function () { + it("should save a identity validation token", function () { + factory.buildStub.returns(collection); + collection.insertStub.returns(BluebirdPromise.resolve()); + + const dataStore = new UserDataStore(factory); + const maxAge = 400; + const token = "TOKEN"; + const challenge = "CHALLENGE"; + + return dataStore.produceIdentityValidationToken(userId, token, challenge, maxAge) + .then(function (doc) { + Assert(collection.insertStub.calledOnce); + Assert(collection.insertStub.calledWith({ + userId: userId, + token: token, + challenge: challenge, + maxDate: Sinon.match.date + })); + return BluebirdPromise.resolve(); + }); + }); + + it("should consume an identity token successfully", function () { + factory.buildStub.returns(collection); + + MockDate.set(100); + + const token = "TOKEN"; + const challenge = "CHALLENGE"; + + collection.findOneStub.withArgs().returns(BluebirdPromise.resolve({ + userId: "USER", + token: token, + challenge: challenge, + maxDate: new Date() + })); + collection.removeStub.returns(BluebirdPromise.resolve()); + + const dataStore = new UserDataStore(factory); + + MockDate.set(80); + + return dataStore.consumeIdentityValidationToken(token, challenge) + .then(function (doc) { + MockDate.reset(); + Assert(collection.findOneStub.calledOnce); + Assert(collection.findOneStub.calledWith({ + token: token, + challenge: challenge + })); + + Assert(collection.removeStub.calledOnce); + Assert(collection.removeStub.calledWith({ + token: token, + challenge: challenge + })); + return BluebirdPromise.resolve(); + }); + }); + + it("should consume an expired identity token", function () { + factory.buildStub.returns(collection); + + MockDate.set(0); + + const token = "TOKEN"; + const challenge = "CHALLENGE"; + + collection.findOneStub.withArgs().returns(BluebirdPromise.resolve({ + userId: "USER", + token: token, + challenge: challenge, + maxDate: new Date() + })); + + const dataStore = new UserDataStore(factory); + + MockDate.set(80000); + + return dataStore.consumeIdentityValidationToken(token, challenge) + .then(function () { return BluebirdPromise.reject(new Error("should not be here")); }) + .catch(function () { + MockDate.reset(); + Assert(collection.findOneStub.calledOnce); + Assert(collection.findOneStub.calledWith({ + token: token, + challenge: challenge + })); + return BluebirdPromise.resolve(); + }); + }); + }); +}); diff --git a/themes/black/server/src/lib/storage/UserDataStore.ts b/themes/black/server/src/lib/storage/UserDataStore.ts new file mode 100644 index 00000000..27b0cddb --- /dev/null +++ b/themes/black/server/src/lib/storage/UserDataStore.ts @@ -0,0 +1,143 @@ +import * as BluebirdPromise from "bluebird"; +import * as path from "path"; +import { IUserDataStore } from "./IUserDataStore"; +import { ICollection } from "./ICollection"; +import { ICollectionFactory } from "./ICollectionFactory"; +import { TOTPSecretDocument } from "./TOTPSecretDocument"; +import { U2FRegistrationDocument } from "./U2FRegistrationDocument"; +import { U2FRegistration } from "../../../types/U2FRegistration"; +import { TOTPSecret } from "../../../types/TOTPSecret"; +import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument"; +import { IdentityValidationDocument } from "./IdentityValidationDocument"; + +// Constants + +const IDENTITY_VALIDATION_TOKENS_COLLECTION_NAME = "identity_validation_tokens"; +const AUTHENTICATION_TRACES_COLLECTION_NAME = "authentication_traces"; + +const U2F_REGISTRATIONS_COLLECTION_NAME = "u2f_registrations"; +const TOTP_SECRETS_COLLECTION_NAME = "totp_secrets"; + + +export interface U2FRegistrationKey { + userId: string; + appId: string; +} + +// Source + +export class UserDataStore implements IUserDataStore { + private u2fSecretCollection: ICollection; + private identityCheckTokensCollection: ICollection; + private authenticationTracesCollection: ICollection; + private totpSecretCollection: ICollection; + + private collectionFactory: ICollectionFactory; + + constructor(collectionFactory: ICollectionFactory) { + this.collectionFactory = collectionFactory; + + this.u2fSecretCollection = this.collectionFactory.build(U2F_REGISTRATIONS_COLLECTION_NAME); + this.identityCheckTokensCollection = this.collectionFactory.build(IDENTITY_VALIDATION_TOKENS_COLLECTION_NAME); + this.authenticationTracesCollection = this.collectionFactory.build(AUTHENTICATION_TRACES_COLLECTION_NAME); + this.totpSecretCollection = this.collectionFactory.build(TOTP_SECRETS_COLLECTION_NAME); + } + + saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise { + const newDocument: U2FRegistrationDocument = { + userId: userId, + appId: appId, + registration: registration + }; + + const filter: U2FRegistrationKey = { + userId: userId, + appId: appId + }; + + return this.u2fSecretCollection.update(filter, newDocument, { upsert: true }); + } + + retrieveU2FRegistration(userId: string, appId: string): BluebirdPromise { + const filter: U2FRegistrationKey = { + userId: userId, + appId: appId + }; + return this.u2fSecretCollection.findOne(filter); + } + + saveAuthenticationTrace(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise { + const newDocument: AuthenticationTraceDocument = { + userId: userId, + date: new Date(), + isAuthenticationSuccessful: isAuthenticationSuccessful, + }; + + return this.authenticationTracesCollection.insert(newDocument); + } + + retrieveLatestAuthenticationTraces(userId: string, count: number): BluebirdPromise { + const q = { + userId: userId + }; + + return this.authenticationTracesCollection.find(q, { date: -1 }, count); + } + + produceIdentityValidationToken(userId: string, token: string, challenge: string, maxAge: number): BluebirdPromise { + const newDocument: IdentityValidationDocument = { + userId: userId, + token: token, + challenge: challenge, + maxDate: new Date(new Date().getTime() + maxAge) + }; + + return this.identityCheckTokensCollection.insert(newDocument); + } + + consumeIdentityValidationToken(token: string, challenge: string): BluebirdPromise { + const that = this; + const filter = { + token: token, + challenge: challenge + }; + + let identityValidationDocument: IdentityValidationDocument; + + return this.identityCheckTokensCollection.findOne(filter) + .then(function (doc: IdentityValidationDocument) { + if (!doc) { + return BluebirdPromise.reject(new Error("Registration token does not exist")); + } + + identityValidationDocument = doc; + const current_date = new Date(); + if (current_date > doc.maxDate) + return BluebirdPromise.reject(new Error("Registration token is not valid anymore")); + + return that.identityCheckTokensCollection.remove(filter); + }) + .then(() => { + return BluebirdPromise.resolve(identityValidationDocument); + }); + } + + saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise { + const doc = { + userId: userId, + secret: secret + }; + + const filter = { + userId: userId + }; + return this.totpSecretCollection.update(filter, doc, { upsert: true }); + } + + retrieveTOTPSecret(userId: string): BluebirdPromise { + const filter = { + userId: userId + }; + return this.totpSecretCollection.findOne(filter); + } +} diff --git a/themes/black/server/src/lib/storage/UserDataStoreStub.spec.ts b/themes/black/server/src/lib/storage/UserDataStoreStub.spec.ts new file mode 100644 index 00000000..5ea27a2d --- /dev/null +++ b/themes/black/server/src/lib/storage/UserDataStoreStub.spec.ts @@ -0,0 +1,64 @@ +import Sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); + +import { TOTPSecretDocument } from "./TOTPSecretDocument"; +import { U2FRegistrationDocument } from "./U2FRegistrationDocument"; +import { U2FRegistration } from "../../../types/U2FRegistration"; +import { TOTPSecret } from "../../../types/TOTPSecret"; +import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument"; +import { IdentityValidationDocument } from "./IdentityValidationDocument"; +import { IUserDataStore } from "./IUserDataStore"; + +export class UserDataStoreStub implements IUserDataStore { + saveU2FRegistrationStub: Sinon.SinonStub; + retrieveU2FRegistrationStub: Sinon.SinonStub; + saveAuthenticationTraceStub: Sinon.SinonStub; + retrieveLatestAuthenticationTracesStub: Sinon.SinonStub; + produceIdentityValidationTokenStub: Sinon.SinonStub; + consumeIdentityValidationTokenStub: Sinon.SinonStub; + saveTOTPSecretStub: Sinon.SinonStub; + retrieveTOTPSecretStub: Sinon.SinonStub; + + constructor() { + this.saveU2FRegistrationStub = Sinon.stub(); + this.retrieveU2FRegistrationStub = Sinon.stub(); + this.saveAuthenticationTraceStub = Sinon.stub(); + this.retrieveLatestAuthenticationTracesStub = Sinon.stub(); + this.produceIdentityValidationTokenStub = Sinon.stub(); + this.consumeIdentityValidationTokenStub = Sinon.stub(); + this.saveTOTPSecretStub = Sinon.stub(); + this.retrieveTOTPSecretStub = Sinon.stub(); + } + + saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise { + return this.saveU2FRegistrationStub(userId, appId, registration); + } + + retrieveU2FRegistration(userId: string, appId: string): BluebirdPromise { + return this.retrieveU2FRegistrationStub(userId, appId); + } + + saveAuthenticationTrace(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise { + return this.saveAuthenticationTraceStub(userId, isAuthenticationSuccessful); + } + + retrieveLatestAuthenticationTraces(userId: string, count: number): BluebirdPromise { + return this.retrieveLatestAuthenticationTracesStub(userId, count); + } + + produceIdentityValidationToken(userId: string, token: string, challenge: string, maxAge: number): BluebirdPromise { + return this.produceIdentityValidationTokenStub(userId, token, challenge, maxAge); + } + + consumeIdentityValidationToken(token: string, challenge: string): BluebirdPromise { + return this.consumeIdentityValidationTokenStub(token, challenge); + } + + saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise { + return this.saveTOTPSecretStub(userId, secret); + } + + retrieveTOTPSecret(userId: string): BluebirdPromise { + return this.retrieveTOTPSecretStub(userId); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/storage/mongo/MongoCollection.spec.ts b/themes/black/server/src/lib/storage/mongo/MongoCollection.spec.ts new file mode 100644 index 00000000..74a773a1 --- /dev/null +++ b/themes/black/server/src/lib/storage/mongo/MongoCollection.spec.ts @@ -0,0 +1,110 @@ +import Assert = require("assert"); +import Sinon = require("sinon"); +import MongoDB = require("mongodb"); +import BluebirdPromise = require("bluebird"); +import { MongoClientStub } from "../../connectors/mongo/MongoClientStub.spec"; +import { MongoCollection } from "./MongoCollection"; + +describe("storage/mongo/MongoCollection", function () { + let mongoCollectionStub: any; + let mongoClientStub: MongoClientStub; + let findStub: Sinon.SinonStub; + let findOneStub: Sinon.SinonStub; + let insertOneStub: Sinon.SinonStub; + let updateStub: Sinon.SinonStub; + let removeStub: Sinon.SinonStub; + let countStub: Sinon.SinonStub; + const COLLECTION_NAME = "collection"; + + before(function () { + mongoClientStub = new MongoClientStub(); + mongoCollectionStub = Sinon.createStubInstance(require("mongodb").Collection as any); + findStub = mongoCollectionStub.find as Sinon.SinonStub; + findOneStub = mongoCollectionStub.findOne as Sinon.SinonStub; + insertOneStub = mongoCollectionStub.insertOne as Sinon.SinonStub; + updateStub = mongoCollectionStub.update as Sinon.SinonStub; + removeStub = mongoCollectionStub.remove as Sinon.SinonStub; + countStub = mongoCollectionStub.count as Sinon.SinonStub; + mongoClientStub.collectionStub.returns( + BluebirdPromise.resolve(mongoCollectionStub) + ); + }); + + describe("find", function () { + it("should find a document in the collection", function () { + const collection = new MongoCollection(COLLECTION_NAME, mongoClientStub); + findStub.returns({ + sort: Sinon.stub().returns({ + limit: Sinon.stub().returns({ + toArray: Sinon.stub().returns(BluebirdPromise.resolve([])) + }) + }) + }); + + return collection.find({ key: "KEY" }) + .then(function () { + Assert(findStub.calledWith({ key: "KEY" })); + }); + }); + }); + + describe("findOne", function () { + it("should find one document in the collection", function () { + const collection = new MongoCollection(COLLECTION_NAME, mongoClientStub); + findOneStub.returns(BluebirdPromise.resolve({})); + + return collection.findOne({ key: "KEY" }) + .then(function () { + Assert(findOneStub.calledWith({ key: "KEY" })); + }); + }); + }); + + describe("insert", function () { + it("should insert a document in the collection", function () { + const collection = new MongoCollection(COLLECTION_NAME, mongoClientStub); + insertOneStub.returns(BluebirdPromise.resolve({})); + + return collection.insert({ key: "KEY" }) + .then(function () { + Assert(insertOneStub.calledWith({ key: "KEY" })); + }); + }); + }); + + describe("update", function () { + it("should update a document in the collection", function () { + const collection = new MongoCollection(COLLECTION_NAME, mongoClientStub); + updateStub.returns(BluebirdPromise.resolve({})); + + return collection.update({ key: "KEY" }, { key: "KEY", value: 1 }) + .then(function () { + Assert(updateStub.calledWith({ key: "KEY" }, { key: "KEY", value: 1 })); + }); + }); + }); + + describe("remove", function () { + it("should remove a document in the collection", function () { + const collection = new MongoCollection(COLLECTION_NAME, mongoClientStub); + removeStub.returns(BluebirdPromise.resolve({})); + + return collection.remove({ key: "KEY" }) + .then(function () { + Assert(removeStub.calledWith({ key: "KEY" })); + }); + }); + }); + + describe("count", function () { + it("should count documents in the collection", function () { + const collection = new MongoCollection(COLLECTION_NAME, mongoClientStub); + countStub.returns(BluebirdPromise.resolve({})); + + return collection.count({ key: "KEY" }) + .then(function () { + Assert(countStub.calledWith({ key: "KEY" })); + }); + }); + }); +}); diff --git a/themes/black/server/src/lib/storage/mongo/MongoCollection.ts b/themes/black/server/src/lib/storage/mongo/MongoCollection.ts new file mode 100644 index 00000000..9771389f --- /dev/null +++ b/themes/black/server/src/lib/storage/mongo/MongoCollection.ts @@ -0,0 +1,50 @@ +import Bluebird = require("bluebird"); +import { ICollection } from "../ICollection"; +import MongoDB = require("mongodb"); +import { IMongoClient } from "../../connectors/mongo/IMongoClient"; + + +export class MongoCollection implements ICollection { + private mongoClient: IMongoClient; + private collectionName: string; + + constructor(collectionName: string, mongoClient: IMongoClient) { + this.collectionName = collectionName; + this.mongoClient = mongoClient; + } + + private collection(): Bluebird { + return this.mongoClient.collection(this.collectionName); + } + + find(query: any, sortKeys?: any, count?: number): Bluebird { + return this.collection() + .then((collection) => collection.find(query).sort(sortKeys).limit(count)) + .then((query) => query.toArray()); + } + + findOne(query: any): Bluebird { + return this.collection() + .then((collection) => collection.findOne(query)); + } + + update(query: any, updateQuery: any, options?: any): Bluebird { + return this.collection() + .then((collection) => collection.update(query, updateQuery, options)); + } + + remove(query: any): Bluebird { + return this.collection() + .then((collection) => collection.remove(query)); + } + + insert(document: any): Bluebird { + return this.collection() + .then((collection) => collection.insertOne(document)); + } + + count(query: any): Bluebird { + return this.collection() + .then((collection) => collection.count(query)); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/storage/mongo/MongoCollectionFactory.spec.ts b/themes/black/server/src/lib/storage/mongo/MongoCollectionFactory.spec.ts new file mode 100644 index 00000000..bd959cac --- /dev/null +++ b/themes/black/server/src/lib/storage/mongo/MongoCollectionFactory.spec.ts @@ -0,0 +1,21 @@ +import Assert = require("assert"); +import Sinon = require("sinon"); +import { MongoClientStub } from "../../connectors/mongo/MongoClientStub.spec"; +import { MongoCollectionFactory } from "./MongoCollectionFactory"; + +describe("storage/mongo/MongoCollectionFactory", function () { + let mongoClient: MongoClientStub; + + before(function() { + mongoClient = new MongoClientStub(); + }); + + describe("create", function () { + it("should create a collection", function () { + const COLLECTION_NAME = "COLLECTION_NAME"; + + const factory = new MongoCollectionFactory(mongoClient); + Assert(factory.build(COLLECTION_NAME)); + }); + }); +}); diff --git a/themes/black/server/src/lib/storage/mongo/MongoCollectionFactory.ts b/themes/black/server/src/lib/storage/mongo/MongoCollectionFactory.ts new file mode 100644 index 00000000..14a8262c --- /dev/null +++ b/themes/black/server/src/lib/storage/mongo/MongoCollectionFactory.ts @@ -0,0 +1,19 @@ +import BluebirdPromise = require("bluebird"); +import { ICollection } from "../ICollection"; +import { ICollectionFactory } from "../ICollectionFactory"; +import { MongoCollection } from "./MongoCollection"; +import path = require("path"); +import MongoDB = require("mongodb"); +import { IMongoClient } from "../../connectors/mongo/IMongoClient"; + +export class MongoCollectionFactory implements ICollectionFactory { + private mongoClient: IMongoClient; + + constructor(mongoClient: IMongoClient) { + this.mongoClient = mongoClient; + } + + build(collectionName: string): ICollection { + return new MongoCollection(collectionName, this.mongoClient); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/storage/nedb/NedbCollection.spec.ts b/themes/black/server/src/lib/storage/nedb/NedbCollection.spec.ts new file mode 100644 index 00000000..a69962b6 --- /dev/null +++ b/themes/black/server/src/lib/storage/nedb/NedbCollection.spec.ts @@ -0,0 +1,136 @@ +import Sinon = require("sinon"); +import Assert = require("assert"); + +import { NedbCollection } from "./NedbCollection"; + +describe("storage/nedb/NedbCollection", function () { + describe("insert", function () { + it("should insert one entry", function () { + const nedbOptions = { + inMemoryOnly: true + }; + const collection = new NedbCollection(nedbOptions); + + collection.insert({ key: "coucou" }); + + return collection.count({}).then(function (count: number) { + Assert.equal(1, count); + }); + }); + + it("should insert three entries", function () { + const nedbOptions = { + inMemoryOnly: true + }; + const collection = new NedbCollection(nedbOptions); + + collection.insert({ key: "coucou" }); + collection.insert({ key: "hello" }); + collection.insert({ key: "hey" }); + + return collection.count({}).then(function (count: number) { + Assert.equal(3, count); + }); + }); + }); + + describe("find", function () { + let collection: NedbCollection; + before(function () { + const nedbOptions = { + inMemoryOnly: true + }; + collection = new NedbCollection(nedbOptions); + + collection.insert({ key: "coucou", value: 1 }); + collection.insert({ key: "hello" }); + collection.insert({ key: "hey" }); + collection.insert({ key: "coucou", value: 2 }); + }); + + it("should find one hello", function () { + return collection.find({ key: "hello" }, { key: 1 }) + .then(function (docs: { key: string }[]) { + Assert.equal(1, docs.length); + Assert(docs[0].key == "hello"); + }); + }); + + it("should find two coucou", function () { + return collection.find({ key: "coucou" }, { value: 1 }) + .then(function (docs: { value: number }[]) { + Assert.equal(2, docs.length); + }); + }); + }); + + describe("findOne", function () { + let collection: NedbCollection; + before(function () { + const nedbOptions = { + inMemoryOnly: true + }; + collection = new NedbCollection(nedbOptions); + + collection.insert({ key: "coucou", value: 1 }); + collection.insert({ key: "coucou", value: 1 }); + collection.insert({ key: "coucou", value: 1 }); + collection.insert({ key: "coucou", value: 1 }); + }); + + it("should find two coucou", function () { + const doc = { key: "coucou", value: 1 }; + return collection.count(doc) + .then(function (count: number) { + Assert.equal(4, count); + return collection.findOne(doc); + }); + }); + }); + + describe("update", function () { + let collection: NedbCollection; + before(function () { + const nedbOptions = { + inMemoryOnly: true + }; + collection = new NedbCollection(nedbOptions); + + collection.insert({ key: "coucou", value: 1 }); + }); + + it("should update the value", function () { + return collection.update({ key: "coucou" }, { key: "coucou", value: 2 }, { multi: true }) + .then(function () { + return collection.find({ key: "coucou" }); + }) + .then(function (docs: { key: string, value: number }[]) { + Assert.equal(1, docs.length); + Assert.equal(2, docs[0].value); + }); + }); + }); + + describe("update", function () { + let collection: NedbCollection; + before(function () { + const nedbOptions = { + inMemoryOnly: true + }; + collection = new NedbCollection(nedbOptions); + + collection.insert({ key: "coucou" }); + collection.insert({ key: "hello" }); + }); + + it("should update the value", function () { + return collection.remove({ key: "coucou" }) + .then(function () { + return collection.count({}); + }) + .then(function (count: number) { + Assert.equal(1, count); + }); + }); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/storage/nedb/NedbCollection.ts b/themes/black/server/src/lib/storage/nedb/NedbCollection.ts new file mode 100644 index 00000000..88a93ad0 --- /dev/null +++ b/themes/black/server/src/lib/storage/nedb/NedbCollection.ts @@ -0,0 +1,47 @@ +import BluebirdPromise = require("bluebird"); +import { ICollection } from "../ICollection"; +import Nedb = require("nedb"); + +declare module "nedb" { + export class NedbAsync extends Nedb { + constructor(pathOrOptions?: string | Nedb.DataStoreOptions); + updateAsync(query: any, updateQuery: any, options?: Nedb.UpdateOptions): BluebirdPromise; + findOneAsync(query: any): BluebirdPromise; + insertAsync(newDoc: T): BluebirdPromise; + removeAsync(query: any): BluebirdPromise; + countAsync(query: any): BluebirdPromise; + } +} + +export class NedbCollection implements ICollection { + private collection: Nedb.NedbAsync; + + constructor(options: Nedb.DataStoreOptions) { + this.collection = BluebirdPromise.promisifyAll(new Nedb(options)) as Nedb.NedbAsync; + } + + find(query: any, sortKeys?: any, count?: number): BluebirdPromise { + const q = this.collection.find(query).sort(sortKeys).limit(count); + return BluebirdPromise.promisify(q.exec, { context: q })(); + } + + findOne(query: any): BluebirdPromise { + return this.collection.findOneAsync(query); + } + + update(query: any, updateQuery: any, options?: any): BluebirdPromise { + return this.collection.updateAsync(query, updateQuery, options); + } + + remove(query: any): BluebirdPromise { + return this.collection.removeAsync(query); + } + + insert(document: any): BluebirdPromise { + return this.collection.insertAsync(document); + } + + count(query: any): BluebirdPromise { + return this.collection.countAsync(query); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/storage/nedb/NedbCollectionFactory.spec.ts b/themes/black/server/src/lib/storage/nedb/NedbCollectionFactory.spec.ts new file mode 100644 index 00000000..da90c661 --- /dev/null +++ b/themes/black/server/src/lib/storage/nedb/NedbCollectionFactory.spec.ts @@ -0,0 +1,16 @@ +import Sinon = require("sinon"); +import Assert = require("assert"); + +import { NedbCollectionFactory } from "./NedbCollectionFactory"; + +describe("storage/nedb/NedbCollectionFactory", function() { + it("should create a nedb collection", function() { + const nedbOptions = { + inMemoryOnly: true + }; + const factory = new NedbCollectionFactory(nedbOptions); + + const collection = factory.build("mycollection"); + Assert(collection); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/storage/nedb/NedbCollectionFactory.ts b/themes/black/server/src/lib/storage/nedb/NedbCollectionFactory.ts new file mode 100644 index 00000000..49c4dc85 --- /dev/null +++ b/themes/black/server/src/lib/storage/nedb/NedbCollectionFactory.ts @@ -0,0 +1,28 @@ +import { ICollection } from "../ICollection"; +import { ICollectionFactory } from "../ICollectionFactory"; +import { NedbCollection } from "./NedbCollection"; +import path = require("path"); +import Nedb = require("nedb"); + +export interface NedbOptions { + inMemoryOnly?: boolean; + directory?: string; +} + +export class NedbCollectionFactory implements ICollectionFactory { + private options: Nedb.DataStoreOptions; + + constructor(options: Nedb.DataStoreOptions) { + this.options = options; + } + + build(collectionName: string): ICollection { + const datastoreOptions: Nedb.DataStoreOptions = { + inMemoryOnly: this.options.inMemoryOnly || false, + autoload: true, + filename: (this.options.filename) ? path.resolve(this.options.filename, collectionName) : undefined + }; + + return new NedbCollection(datastoreOptions); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/stubs/express.spec.ts b/themes/black/server/src/lib/stubs/express.spec.ts new file mode 100644 index 00000000..48f15d7e --- /dev/null +++ b/themes/black/server/src/lib/stubs/express.spec.ts @@ -0,0 +1,103 @@ + +import sinon = require("sinon"); +import express = require("express"); + +export interface RequestMock { + app?: any; + body?: any; + session?: any; + headers?: any; + get?: any; + query?: any; + originalUrl: string; +} + +export interface ResponseMock { + send: sinon.SinonStub | sinon.SinonSpy; + sendStatus: sinon.SinonStub; + sendFile: sinon.SinonStub; + sendfile: sinon.SinonStub; + status: sinon.SinonStub | sinon.SinonSpy; + json: sinon.SinonStub | sinon.SinonSpy; + links: sinon.SinonStub; + jsonp: sinon.SinonStub; + download: sinon.SinonStub; + contentType: sinon.SinonStub; + type: sinon.SinonStub; + format: sinon.SinonStub; + attachment: sinon.SinonStub; + set: sinon.SinonStub; + header: sinon.SinonStub; + headersSent: boolean; + get: sinon.SinonStub; + clearCookie: sinon.SinonStub; + cookie: sinon.SinonStub; + location: sinon.SinonStub; + redirect: sinon.SinonStub | sinon.SinonSpy; + render: sinon.SinonStub | sinon.SinonSpy; + locals: sinon.SinonStub; + charset: string; + vary: sinon.SinonStub; + app: any; + write: sinon.SinonStub; + writeContinue: sinon.SinonStub; + writeHead: sinon.SinonStub; + statusCode: number; + statusMessage: string; + setHeader: sinon.SinonStub; + setTimeout: sinon.SinonStub; + sendDate: boolean; + getHeader: sinon.SinonStub; +} + +export function RequestMock(): RequestMock { + return { + originalUrl: "/non-api/xxx", + app: { + get: sinon.stub() + }, + headers: { + "x-forwarded-for": "127.0.0.1" + }, + session: {} + }; +} +export function ResponseMock(): ResponseMock { + return { + send: sinon.stub(), + status: sinon.stub(), + json: sinon.stub(), + sendStatus: sinon.stub(), + links: sinon.stub(), + jsonp: sinon.stub(), + sendFile: sinon.stub(), + sendfile: sinon.stub(), + download: sinon.stub(), + contentType: sinon.stub(), + type: sinon.stub(), + format: sinon.stub(), + attachment: sinon.stub(), + set: sinon.stub(), + header: sinon.stub(), + headersSent: true, + get: sinon.stub(), + clearCookie: sinon.stub(), + cookie: sinon.stub(), + location: sinon.stub(), + redirect: sinon.stub(), + render: sinon.stub(), + locals: sinon.stub(), + charset: "utf-8", + vary: sinon.stub(), + app: sinon.stub(), + write: sinon.stub(), + writeContinue: sinon.stub(), + writeHead: sinon.stub(), + statusCode: 200, + statusMessage: "message", + setHeader: sinon.stub(), + setTimeout: sinon.stub(), + sendDate: true, + getHeader: sinon.stub() + }; +} diff --git a/themes/black/server/src/lib/stubs/ldapjs.spec.ts b/themes/black/server/src/lib/stubs/ldapjs.spec.ts new file mode 100644 index 00000000..045c0e11 --- /dev/null +++ b/themes/black/server/src/lib/stubs/ldapjs.spec.ts @@ -0,0 +1,50 @@ + +import Sinon = require("sinon"); + +export class LdapjsMock { + createClientStub: sinon.SinonStub; + + constructor() { + this.createClientStub = Sinon.stub(); + } + + createClient(params: any) { + return this.createClientStub(params); + } +} + +export class LdapjsClientMock { + bindStub: sinon.SinonStub; + unbindStub: sinon.SinonStub; + searchStub: sinon.SinonStub; + modifyStub: sinon.SinonStub; + onStub: sinon.SinonStub; + + constructor() { + this.bindStub = Sinon.stub(); + this.unbindStub = Sinon.stub(); + this.searchStub = Sinon.stub(); + this.modifyStub = Sinon.stub(); + this.onStub = Sinon.stub(); + } + + bind() { + return this.bindStub(); + } + + unbind() { + return this.unbindStub(); + } + + search() { + return this.searchStub(); + } + + modify() { + return this.modifyStub(); + } + + on() { + return this.onStub(); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/stubs/speakeasy.spec.ts b/themes/black/server/src/lib/stubs/speakeasy.spec.ts new file mode 100644 index 00000000..023614dc --- /dev/null +++ b/themes/black/server/src/lib/stubs/speakeasy.spec.ts @@ -0,0 +1,7 @@ + +import sinon = require("sinon"); + +export = { + totp: sinon.stub(), + generateSecret: sinon.stub() +}; diff --git a/themes/black/server/src/lib/stubs/u2f.spec.ts b/themes/black/server/src/lib/stubs/u2f.spec.ts new file mode 100644 index 00000000..234b28c1 --- /dev/null +++ b/themes/black/server/src/lib/stubs/u2f.spec.ts @@ -0,0 +1,16 @@ + +import sinon = require("sinon"); + +export interface U2FMock { + request: sinon.SinonStub; + checkSignature: sinon.SinonStub; + checkRegistration: sinon.SinonStub; +} + +export function U2FMock(): U2FMock { + return { + request: sinon.stub(), + checkSignature: sinon.stub(), + checkRegistration: sinon.stub() + }; +} diff --git a/themes/black/server/src/lib/utils/HashGenerator.spec.ts b/themes/black/server/src/lib/utils/HashGenerator.spec.ts new file mode 100644 index 00000000..f19619a6 --- /dev/null +++ b/themes/black/server/src/lib/utils/HashGenerator.spec.ts @@ -0,0 +1,18 @@ +import Assert = require("assert"); +import { HashGenerator } from "./HashGenerator"; + +describe("utils/HashGenerator", function () { + it("should compute correct ssha512 (password)", function () { + 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", 500000, "abcdefghijklmnop") + .then(function (hash: string) { + Assert.equal(hash, "{CRYPT}$6$rounds=500000$abcdefghijklmnop$sTlNGf0VO/HTQIOXemmaBbV28HUch/qhWOA1/4dsDj6CDQYhUgXbYSPL6gccAsWMr2zD5fFWwhKmPdG.yxphs."); + }); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/utils/HashGenerator.ts b/themes/black/server/src/lib/utils/HashGenerator.ts new file mode 100644 index 00000000..e67de32b --- /dev/null +++ b/themes/black/server/src/lib/utils/HashGenerator.ts @@ -0,0 +1,23 @@ +import BluebirdPromise = require("bluebird"); +import RandomString = require("randomstring"); +import Util = require("util"); +const crypt = require("crypt3"); + +export class HashGenerator { + static ssha512( + password: string, + rounds: number = 500000, + salt?: string): BluebirdPromise { + const saltSize = 16; + // $6 means SHA512 + const _salt = Util.format("$6$rounds=%d$%s", rounds, + (salt) ? salt : RandomString.generate(16)); + + const cryptAsync = BluebirdPromise.promisify(crypt); + + return cryptAsync(password, _salt) + .then(function (hash: string) { + return BluebirdPromise.resolve(Util.format("{CRYPT}%s", hash)); + }); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/utils/ObjectCloner.ts b/themes/black/server/src/lib/utils/ObjectCloner.ts new file mode 100644 index 00000000..3e125d74 --- /dev/null +++ b/themes/black/server/src/lib/utils/ObjectCloner.ts @@ -0,0 +1,6 @@ + +export class ObjectCloner { + static clone(obj: any): any { + return JSON.parse(JSON.stringify(obj)); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/utils/SafeRedirection.spec.ts b/themes/black/server/src/lib/utils/SafeRedirection.spec.ts new file mode 100644 index 00000000..4126949f --- /dev/null +++ b/themes/black/server/src/lib/utils/SafeRedirection.spec.ts @@ -0,0 +1,33 @@ +import Assert = require("assert"); +import Sinon = require("sinon"); +import { SafeRedirector } from "./SafeRedirection"; + +describe("web_server/middlewares/SafeRedirection", () => { + describe("Url is in protected domain", () => { + before(() => { + this.redirector = new SafeRedirector("example.com"); + this.res = {redirect: Sinon.stub()}; + }); + + it("should redirect to provided url", () => { + this.redirector.redirectOrElse(this.res, + "https://mysubdomain.example.com:8080/abc", + "https://authelia.example.com"); + Assert(this.res.redirect.calledWith("https://mysubdomain.example.com:8080/abc")); + }); + + it("should redirect to default url when wrong domain", () => { + this.redirector.redirectOrElse(this.res, + "https://mysubdomain.domain.rtf:8080/abc", + "https://authelia.example.com"); + Assert(this.res.redirect.calledWith("https://authelia.example.com")); + }); + + it("should redirect to default url when not terminating by domain", () => { + this.redirector.redirectOrElse(this.res, + "https://mysubdomain.example.com.rtf:8080/abc", + "https://authelia.example.com"); + Assert(this.res.redirect.calledWith("https://authelia.example.com")); + }); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/utils/SafeRedirection.ts b/themes/black/server/src/lib/utils/SafeRedirection.ts new file mode 100644 index 00000000..9e6a32e0 --- /dev/null +++ b/themes/black/server/src/lib/utils/SafeRedirection.ts @@ -0,0 +1,22 @@ +import Express = require("express"); +import { DomainExtractor } from "../../../../shared/DomainExtractor"; +import { BelongToDomain } from "../../../../shared/BelongToDomain"; + + +export class SafeRedirector { + private domain: string; + + constructor(domain: string) { + this.domain = domain; + } + + redirectOrElse( + res: Express.Response, + url: string, + defaultUrl: string): void { + if (BelongToDomain(url, this.domain)) { + res.redirect(url); + } + res.redirect(defaultUrl); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/utils/URLDecomposer.spec.ts b/themes/black/server/src/lib/utils/URLDecomposer.spec.ts new file mode 100644 index 00000000..cbb03873 --- /dev/null +++ b/themes/black/server/src/lib/utils/URLDecomposer.spec.ts @@ -0,0 +1,46 @@ +import { URLDecomposer } from "./URLDecomposer"; +import Assert = require("assert"); + +describe("utils/URLDecomposer", function () { + describe("test fromUrl", function () { + it("should return domain from https url", function () { + const d = URLDecomposer.fromUrl("https://www.example.com/test/abc"); + Assert.equal(d.domain, "www.example.com"); + Assert.equal(d.path, "/test/abc"); + }); + + it("should return domain from http url", function () { + const d = URLDecomposer.fromUrl("http://www.example.com/test/abc"); + Assert.equal(d.domain, "www.example.com"); + Assert.equal(d.path, "/test/abc"); + }); + + it("should return domain when url contains port", function () { + const d = URLDecomposer.fromUrl("https://www.example.com:8080/test/abc"); + Assert.equal(d.domain, "www.example.com"); + Assert.equal(d.path, "/test/abc"); + }); + + it("should return default path when no path provided", function () { + const d = URLDecomposer.fromUrl("https://www.example.com:8080"); + Assert.equal(d.domain, "www.example.com"); + Assert.equal(d.path, "/"); + }); + + it("should return default path when provided", function () { + const d = URLDecomposer.fromUrl("https://www.example.com:8080/"); + Assert.equal(d.domain, "www.example.com"); + Assert.equal(d.path, "/"); + }); + + it("should return undefined when does not match", function () { + const d = URLDecomposer.fromUrl("https:///abc/test"); + Assert.equal(d, undefined); + }); + + it("should return undefined when does not match", function () { + const d = URLDecomposer.fromUrl("https:///abc/test"); + Assert.equal(d, undefined); + }); + }); +}); \ No newline at end of file diff --git a/themes/black/server/src/lib/utils/URLDecomposer.ts b/themes/black/server/src/lib/utils/URLDecomposer.ts new file mode 100644 index 00000000..9bdf2e9d --- /dev/null +++ b/themes/black/server/src/lib/utils/URLDecomposer.ts @@ -0,0 +1,15 @@ +export class URLDecomposer { + static fromUrl(url: string): {domain: string, path: string} { + if (!url) return; + const match = url.match(/https?:\/\/([a-z0-9_.-]+)(:[0-9]+)?(.*)/); + + if (!match) return; + + if (match[1] && !match[3]) { + return {domain: match[1], path: "/"}; + } else if (match[1] && match[3]) { + return {domain: match[1], path: match[3]}; + } + return; + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/web_server/Configurator.ts b/themes/black/server/src/lib/web_server/Configurator.ts new file mode 100644 index 00000000..6e404874 --- /dev/null +++ b/themes/black/server/src/lib/web_server/Configurator.ts @@ -0,0 +1,47 @@ +import { Configuration } from "../configuration/schema/Configuration"; +import { GlobalDependencies } from "../../../types/Dependencies"; +import { SessionConfigurationBuilder } from + "../configuration/SessionConfigurationBuilder"; +import Path = require("path"); +import Express = require("express"); +import * as BodyParser from "body-parser"; +import { RestApi } from "./RestApi"; +import { WithHeadersLogged } from "./middlewares/WithHeadersLogged"; +import { ServerVariables } from "../ServerVariables"; +import Helmet = require("helmet"); + +const addRequestId = require("express-request-id")(); + +// Constants +const TRUST_PROXY = "trust proxy"; +const X_POWERED_BY = "x-powered-by"; +const VIEWS = "views"; +const VIEW_ENGINE = "view engine"; +const PUG = "pug"; + +export class Configurator { + static configure(config: Configuration, + app: Express.Application, + vars: ServerVariables, + deps: GlobalDependencies): void { + const viewsDirectory = Path.resolve(__dirname, "../../views"); + const publicHtmlDirectory = Path.resolve(__dirname, "../../public_html"); + + const expressSessionOptions = SessionConfigurationBuilder.build(config, deps); + + app.use(Express.static(publicHtmlDirectory)); + app.use(BodyParser.urlencoded({ extended: false })); + app.use(BodyParser.json()); + app.use(deps.session(expressSessionOptions)); + app.use(addRequestId); + app.use(WithHeadersLogged.middleware(vars.logger)); + app.disable(X_POWERED_BY); + app.enable(TRUST_PROXY); + app.use(Helmet()); + + app.set(VIEWS, viewsDirectory); + app.set(VIEW_ENGINE, PUG); + + RestApi.setup(app, vars); + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/web_server/RestApi.ts b/themes/black/server/src/lib/web_server/RestApi.ts new file mode 100644 index 00000000..9144a15b --- /dev/null +++ b/themes/black/server/src/lib/web_server/RestApi.ts @@ -0,0 +1,125 @@ +import Express = require("express"); + +import FirstFactorGet = require("../routes/firstfactor/get"); +import SecondFactorGet = require("../routes/secondfactor/get"); + +import FirstFactorPost = require("../routes/firstfactor/post"); +import LogoutGet = require("../routes/logout/get"); +import VerifyGet = require("../routes/verify/get"); +import TOTPSignGet = require("../routes/secondfactor/totp/sign/post"); + +import IdentityCheckMiddleware = require("../IdentityCheckMiddleware"); + +import TOTPRegistrationIdentityHandler from "../routes/secondfactor/totp/identity/RegistrationHandler"; +import U2FRegistrationIdentityHandler from "../routes/secondfactor/u2f/identity/RegistrationHandler"; +import ResetPasswordIdentityHandler from "../routes/password-reset/identity/PasswordResetHandler"; + +import U2FSignPost = require("../routes/secondfactor/u2f/sign/post"); +import U2FSignRequestGet = require("../routes/secondfactor/u2f/sign_request/get"); + +import U2FRegisterPost = require("../routes/secondfactor/u2f/register/post"); +import U2FRegisterRequestGet = require("../routes/secondfactor/u2f/register_request/get"); + +import ResetPasswordFormPost = require("../routes/password-reset/form/post"); +import ResetPasswordRequestPost = require("../routes/password-reset/request/get"); + +import Error401Get = require("../routes/error/401/get"); +import Error403Get = require("../routes/error/403/get"); +import Error404Get = require("../routes/error/404/get"); + +import LoggedIn = require("../routes/loggedin/get"); + +import { ServerVariables } from "../ServerVariables"; +import Endpoints = require("../../../../shared/api"); +import { RequireValidatedFirstFactor } from "./middlewares/RequireValidatedFirstFactor"; + +function setupTotp(app: Express.Application, vars: ServerVariables) { + app.post(Endpoints.SECOND_FACTOR_TOTP_POST, + RequireValidatedFirstFactor.middleware(vars.logger), + TOTPSignGet.default(vars)); + + app.get(Endpoints.SECOND_FACTOR_TOTP_IDENTITY_START_GET, + RequireValidatedFirstFactor.middleware(vars.logger)); + + app.get(Endpoints.SECOND_FACTOR_TOTP_IDENTITY_FINISH_GET, + RequireValidatedFirstFactor.middleware(vars.logger)); + + IdentityCheckMiddleware.register(app, + Endpoints.SECOND_FACTOR_TOTP_IDENTITY_START_GET, + Endpoints.SECOND_FACTOR_TOTP_IDENTITY_FINISH_GET, + new TOTPRegistrationIdentityHandler(vars.logger, + vars.userDataStore, vars.totpHandler, vars.config.totp), + vars); +} + +function setupU2f(app: Express.Application, vars: ServerVariables) { + app.get(Endpoints.SECOND_FACTOR_U2F_SIGN_REQUEST_GET, + RequireValidatedFirstFactor.middleware(vars.logger), + U2FSignRequestGet.default(vars)); + + app.post(Endpoints.SECOND_FACTOR_U2F_SIGN_POST, + RequireValidatedFirstFactor.middleware(vars.logger), + U2FSignPost.default(vars)); + + app.get(Endpoints.SECOND_FACTOR_U2F_REGISTER_REQUEST_GET, + RequireValidatedFirstFactor.middleware(vars.logger), + U2FRegisterRequestGet.default(vars)); + + app.post(Endpoints.SECOND_FACTOR_U2F_REGISTER_POST, + RequireValidatedFirstFactor.middleware(vars.logger), + U2FRegisterPost.default(vars)); + + app.get(Endpoints.SECOND_FACTOR_U2F_IDENTITY_START_GET, + RequireValidatedFirstFactor.middleware(vars.logger)); + + app.get(Endpoints.SECOND_FACTOR_U2F_IDENTITY_FINISH_GET, + RequireValidatedFirstFactor.middleware(vars.logger)); + + IdentityCheckMiddleware.register(app, + Endpoints.SECOND_FACTOR_U2F_IDENTITY_START_GET, + Endpoints.SECOND_FACTOR_U2F_IDENTITY_FINISH_GET, + new U2FRegistrationIdentityHandler(vars.logger), vars); +} + +function setupResetPassword(app: Express.Application, vars: ServerVariables) { + IdentityCheckMiddleware.register(app, + Endpoints.RESET_PASSWORD_IDENTITY_START_GET, + Endpoints.RESET_PASSWORD_IDENTITY_FINISH_GET, + new ResetPasswordIdentityHandler(vars.logger, vars.usersDatabase), + vars); + + app.get(Endpoints.RESET_PASSWORD_REQUEST_GET, + ResetPasswordRequestPost.default); + app.post(Endpoints.RESET_PASSWORD_FORM_POST, + ResetPasswordFormPost.default(vars)); +} + +function setupErrors(app: Express.Application, vars: ServerVariables) { + app.get(Endpoints.ERROR_401_GET, Error401Get.default(vars)); + app.get(Endpoints.ERROR_403_GET, Error403Get.default(vars)); + app.get(Endpoints.ERROR_404_GET, Error404Get.default); +} + +export class RestApi { + static setup(app: Express.Application, vars: ServerVariables): void { + app.get(Endpoints.FIRST_FACTOR_GET, FirstFactorGet.default(vars)); + + app.get(Endpoints.SECOND_FACTOR_GET, + RequireValidatedFirstFactor.middleware(vars.logger), + SecondFactorGet.default(vars)); + + app.get(Endpoints.LOGOUT_GET, LogoutGet.default(vars)); + + app.get(Endpoints.VERIFY_GET, VerifyGet.default(vars)); + app.post(Endpoints.FIRST_FACTOR_POST, FirstFactorPost.default(vars)); + + setupTotp(app, vars); + setupU2f(app, vars); + setupResetPassword(app, vars); + setupErrors(app, vars); + + app.get(Endpoints.LOGGED_IN, + RequireValidatedFirstFactor.middleware(vars.logger), + LoggedIn.default(vars)); + } +} diff --git a/themes/black/server/src/lib/web_server/middlewares/RequireValidatedFirstFactor.ts b/themes/black/server/src/lib/web_server/middlewares/RequireValidatedFirstFactor.ts new file mode 100644 index 00000000..ecfd7576 --- /dev/null +++ b/themes/black/server/src/lib/web_server/middlewares/RequireValidatedFirstFactor.ts @@ -0,0 +1,27 @@ +import Express = require("express"); +import BluebirdPromise = require("bluebird"); +import ErrorReplies = require("../../ErrorReplies"); +import { IRequestLogger } from "../../logging/IRequestLogger"; +import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler"; +import Exceptions = require("../../Exceptions"); +import { Level } from "../../authentication/Level"; + +export class RequireValidatedFirstFactor { + static middleware(logger: IRequestLogger) { + return function (req: Express.Request, res: Express.Response, + next: Express.NextFunction): BluebirdPromise { + + return new BluebirdPromise(function (resolve, reject) { + const authSession = AuthenticationSessionHandler.get(req, logger); + if (!authSession.userid || authSession.authentication_level < Level.ONE_FACTOR) + return reject( + new Exceptions.FirstFactorValidationError( + "First factor has not been validated yet.")); + + next(); + resolve(); + }) + .catch(ErrorReplies.replyWithError401(req, res, logger)); + }; + } +} \ No newline at end of file diff --git a/themes/black/server/src/lib/web_server/middlewares/WithHeadersLogged.ts b/themes/black/server/src/lib/web_server/middlewares/WithHeadersLogged.ts new file mode 100644 index 00000000..139db114 --- /dev/null +++ b/themes/black/server/src/lib/web_server/middlewares/WithHeadersLogged.ts @@ -0,0 +1,12 @@ +import Express = require("express"); +import { IRequestLogger } from "../../logging/IRequestLogger"; + +export class WithHeadersLogged { + static middleware(logger: IRequestLogger) { + return function (req: Express.Request, res: Express.Response, + next: Express.NextFunction): void { + logger.debug(req, "Headers = %s", JSON.stringify(req.headers)); + next(); + }; + } +} \ No newline at end of file diff --git a/themes/black/server/src/resources/email-template.ejs b/themes/black/server/src/resources/email-template.ejs new file mode 100644 index 00000000..f59c2f94 --- /dev/null +++ b/themes/black/server/src/resources/email-template.ejs @@ -0,0 +1,254 @@ + + + + + + Simples-Minimalistic Responsive Template + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + +
 
+ + + + + + + +
+

<%= title %>

+
+ +
 
+
+
+ + + + + + + + +
+ + + + + + +
 
+
+ + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + +
 
+ + + + + + + + + + + + + + + + + + +
+ This email has been sent to you in order to validate your identity. Please ignore it if you do not know why you received it. +
 
+ <%= button_title %> +
+
 
+
+
+ + + + + + + + +
+ + + + + + + + + + + + +
 
 
 
+
+ + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + +
+ Please ignore this email if you did not initiate the process. +
+
+
+ + + + + diff --git a/themes/black/server/src/views/already-logged-in.pug b/themes/black/server/src/views/already-logged-in.pug new file mode 100644 index 00000000..137bbea3 --- /dev/null +++ b/themes/black/server/src/views/already-logged-in.pug @@ -0,0 +1,14 @@ +extends layout/layout.pug + +block form-header + h1 Sign in + +block content + img(class="header-img" src="/img/success.png" alt="success") + if redirection_url + p You are already logged in as #{ username }.

+ | If you are not redirected in few seconds, click here.

+ | Otherwise, click here to log off. + else + p You are already logged in as #{ username }.

+ | Click here to log off. diff --git a/themes/black/server/src/views/errors/.directory b/themes/black/server/src/views/errors/.directory new file mode 100644 index 00000000..33f71bea --- /dev/null +++ b/themes/black/server/src/views/errors/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,17,20,59,57 +Version=3 +ViewMode=1 diff --git a/themes/black/server/src/views/errors/401.pug b/themes/black/server/src/views/errors/401.pug new file mode 100644 index 00000000..b7a222ad --- /dev/null +++ b/themes/black/server/src/views/errors/401.pug @@ -0,0 +1,16 @@ +extends ../layout/layout.pug + +block variables + - page_classname = "error-401"; + +block form-header + h1 Error 401 + +block content + img(class="header-img" src="/img/warning.png" alt="warning") + if redirection_url + p You are not authorized to access this resource.

+ | Please click here if you are not + | redirected in few seconds. + else + p You are not authorized to access this resource. \ No newline at end of file diff --git a/themes/black/server/src/views/errors/403.pug b/themes/black/server/src/views/errors/403.pug new file mode 100644 index 00000000..f4b5ca8a --- /dev/null +++ b/themes/black/server/src/views/errors/403.pug @@ -0,0 +1,16 @@ +extends ../layout/layout.pug + +block variables + - page_classname = "error-403"; + +block form-header + h1 Error 403 + +block content + img(class="header-img" src="/img/warning.png" alt="warning") + if redirection_url + p You don't have enough privileges to access this resource.

+ | Please click here if you are not + | redirected in few seconds. + else + p You don't have enough privileges to access this resource. diff --git a/themes/black/server/src/views/errors/404.pug b/themes/black/server/src/views/errors/404.pug new file mode 100644 index 00000000..06d6375f --- /dev/null +++ b/themes/black/server/src/views/errors/404.pug @@ -0,0 +1,11 @@ +extends ../layout/layout.pug + +block variables + - page_classname = "error-404"; + +block form-header +

Error 404

+ +block content + img(class="header-img" src="/img/warning.png" alt="warning") + p Page not found. diff --git a/themes/black/server/src/views/firstfactor.pug b/themes/black/server/src/views/firstfactor.pug new file mode 100644 index 00000000..57447071 --- /dev/null +++ b/themes/black/server/src/views/firstfactor.pug @@ -0,0 +1,23 @@ +extends layout/layout.pug + +block variables + - page_classname = "firstfactor"; + +block form-header + h1 Sign in + +block content + div(class="notification") + img(class="header-img" src="/img/sharingan.png" alt="user profile") + p Enter your credentials to sign in + form(class="form-signin") + div(class="form-inputs") + input(type="text" class="form-control" id="username" placeholder="Username" required autofocus) + input(type="password" class="form-control" id="password" placeholder="Password" required) + button(id="signin" class="btn btn-lg btn-primary btn-block" type="submit") Sign in + div(class="keep-me-logged-in pull-left") + input(type="checkbox" id="keep_me_logged_in" name="keep_me_logged_in" value="true") + label(for="keep_me_logged_in") Keep me logged in + div(class="bottom-right-links pull-right") + a(href=reset_password_request_endpoint, class="link forgot-password") Forgot password? + span(class="clearfix") diff --git a/themes/black/server/src/views/layout/layout.pug b/themes/black/server/src/views/layout/layout.pug new file mode 100644 index 00000000..43247436 --- /dev/null +++ b/themes/black/server/src/views/layout/layout.pug @@ -0,0 +1,28 @@ +block variables + +doctype html +html + head + title Authelia - 2FA + meta(name="viewport", content="width=device-width, initial-scale=1.0") + meta(name="robots", content="noindex, nofollow, nosnippet, noarchive") + meta(http-equiv="Content-Security-Policy", content="default-src 'self'; img-src 'self' data:;") + link(rel="icon", href="/img/icon.png" type="image/png" sizes="32x32") + link(rel="stylesheet", type="text/css", href="/css/authelia.css") + if redirection_url + meta(http-equiv="refresh" content="4;url=" + redirection_url) + body + div(class="container") + div(class="row") + div(class="col-xs-12 col-sm-8 col-sm-offset-2 col-md-6 col-md-offset-3 col-lg-6 col-lg-offset-3") + div(class="account-wall " + page_classname) + div(class="row header") + block form-header + div(class="row body") + div(class="form col-xs-10 col-xs-offset-1 col-sm-8 col-sm-offset-2 col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2") + block content + div(class="row footer poweredby-block") + div(class="poweredby col-xs-6 col-xs-offset-4 col-sm-6 col-sm-offset-4 col-md-6 col-md-offset-4") + | Powered by Authelia + block entrypoint + script(src="/js/authelia.js", type="text/javascript") diff --git a/themes/black/server/src/views/need-identity-validation.pug b/themes/black/server/src/views/need-identity-validation.pug new file mode 100644 index 00000000..4cfd6271 --- /dev/null +++ b/themes/black/server/src/views/need-identity-validation.pug @@ -0,0 +1,12 @@ +extends layout/layout.pug + +block variables + - page_classname = "identity-validation"; + +block form-header + h1 Registration + +block content + img(class="header-img" src="/img/mail.png" alt="mail") + p A confirmation email has been sent to your mailbox. + | Please open it and click on the link within 15 minutes to confirm the registration. diff --git a/themes/black/server/src/views/password-reset-form.pug b/themes/black/server/src/views/password-reset-form.pug new file mode 100644 index 00000000..fd931189 --- /dev/null +++ b/themes/black/server/src/views/password-reset-form.pug @@ -0,0 +1,18 @@ +extends layout/layout.pug + +block variables + - page_classname = "password-reset-form"; + +block form-header + h1 Reset password + +block content + div(class="notification") + img(class="header-img" src="/img/password_white.png" alt="password") + p Set your new password and confirm it. + form(class="form-signin") + div(class="form-inputs") + input(class="form-control" type="password" name="password1" id="password1" placeholder="New password" required="required") + input(class="form-control" type="password" name="password2" id="password2" placeholder="Password confirmation" required="required") + button(id="reset-password-button" class="btn btn-lg btn-primary btn-block" type="submit") Reset Password + span(class="clearfix") diff --git a/themes/black/server/src/views/password-reset-request.pug b/themes/black/server/src/views/password-reset-request.pug new file mode 100644 index 00000000..855b5998 --- /dev/null +++ b/themes/black/server/src/views/password-reset-request.pug @@ -0,0 +1,18 @@ +extends layout/layout.pug + +block variables + - page_classname = "password-reset-request"; + +block form-header + h1 Reset password + +block content + div(class="notification") + div + img(class="header-img" src="/img/password_white.png" alt="password") + p After giving your username, you will receive an email to change your password. + form(class="form-signin") + div(class="form-inputs") + input(type="text" class="form-control" name="username" id="username" placeholder="Your username" required="required") + button(id="reset-password-button" class="btn btn-lg btn-primary btn-block" type="submit") Reset Password + span(class="clearfix") diff --git a/themes/black/server/src/views/secondfactor.pug b/themes/black/server/src/views/secondfactor.pug new file mode 100644 index 00000000..87b57818 --- /dev/null +++ b/themes/black/server/src/views/secondfactor.pug @@ -0,0 +1,31 @@ +extends layout/layout.pug + +block variables + - page_classname = "secondfactor"; + +block form-header + h1 Sign in + +block content + div + div(class="notification notification-totp") + h3 Hi #{username} + div(class="row") + div(class="u2f-token") + img(src="/img/pendrive.png", alt="security key") + p + | Please, touch your security key
+ b Or
+ | Get a one-time password + form(class="form-signin totp") + div(class="form-inputs") + input(type="text" autocomplete="off" class="form-control" id="token" placeholder="Token" required autofocus) + button(class="btn btn-lg btn-primary btn-block totp-button" type="submit") Sign in + div(class="pull-right bottom-right-links") + div Need to register? + div + a(href=u2f_identity_start_endpoint, class="link register-u2f", data-toggle="tooltip", title="A security key is required to register.") Security key + | | + a(href=totp_identity_start_endpoint, class="link register-totp") Google Authenticator + span(class="clearfix") + script(src="/js/u2f-api.js", type="text/javascript") diff --git a/themes/black/server/src/views/totp-register.pug b/themes/black/server/src/views/totp-register.pug new file mode 100644 index 00000000..1b4d9835 --- /dev/null +++ b/themes/black/server/src/views/totp-register.pug @@ -0,0 +1,25 @@ +extends layout/layout.pug + +block variables + - page_classname = "totp-register"; + +block form-header + h1 One-time passwords + +block content + p Open Google Authenticator and add this entry + p(id="secret") #{ base32_secret } + p or scan this barcode + div(id="qrcode") #{ otpauth_url } + p + a(href=login_endpoint, id="login-button") Login + div(class="need-google-authenticator") + | Need Google Authenticator? + div(class="store-badges") + a(href='https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1', target="_blank") + img(alt='Get it on Google Play', src='/img/stores/googleplay-badge.svg', class="store-badge") + a(href='https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8', target="_blank") + img(alt='Get it on Apple Store' src='/img/stores/applestore-badge.svg' class="store-badge") + +block entrypoint + script(src="/js/qrcode.min.js", type="text/javascript" ) diff --git a/themes/black/server/src/views/u2f-register.pug b/themes/black/server/src/views/u2f-register.pug new file mode 100644 index 00000000..d52eba6c --- /dev/null +++ b/themes/black/server/src/views/u2f-register.pug @@ -0,0 +1,12 @@ +extends layout/layout.pug + +block variables + - page_classname = "u2f-register"; + +block form-header + h1 Register your security key + +block content + p Touch the token to register your security key. + img(src="/img/pendrive.png" alt="pendrive") + script(src="/js/u2f-api.js", type="text/javascript") \ No newline at end of file diff --git a/themes/black/server/test/requests.ts b/themes/black/server/test/requests.ts new file mode 100644 index 00000000..93fa0de4 --- /dev/null +++ b/themes/black/server/test/requests.ts @@ -0,0 +1,94 @@ + +import BluebirdPromise = require("bluebird"); +import request = require("request"); +import assert = require("assert"); +import express = require("express"); +import nodemailer = require("nodemailer"); +import Endpoints = require("../../shared/api"); + +declare module "request" { + export interface RequestAPI { + getAsync(uri: string, options?: RequiredUriUrl): BluebirdPromise; + getAsync(uri: string): BluebirdPromise; + getAsync(options: RequiredUriUrl & CoreOptions): BluebirdPromise; + + postAsync(uri: string, options?: CoreOptions): BluebirdPromise; + postAsync(uri: string): BluebirdPromise; + postAsync(options: RequiredUriUrl & CoreOptions): BluebirdPromise; + } +} + +const requestAsync: typeof request = BluebirdPromise.promisifyAll(request) as typeof request; + +export = function (port: number) { + const PORT = port; + const BASE_URL = "http://localhost:" + PORT; + + function execute_totp(jar: request.CookieJar, token: string) { + return requestAsync.postAsync({ + url: BASE_URL + Endpoints.SECOND_FACTOR_TOTP_POST, + jar: jar, + form: { + token: token + } + }); + } + + function execute_u2f_authentication(jar: request.CookieJar) { + return requestAsync.getAsync({ + url: BASE_URL + Endpoints.SECOND_FACTOR_U2F_SIGN_REQUEST_GET, + jar: jar + }) + .then(function (res: request.RequestResponse) { + assert.equal(res.statusCode, 200); + return requestAsync.postAsync({ + url: BASE_URL + Endpoints.SECOND_FACTOR_U2F_SIGN_POST, + jar: jar, + form: { + } + }); + }); + } + + function execute_verification(jar: request.CookieJar) { + return requestAsync.getAsync({ url: BASE_URL + Endpoints.VERIFY_GET, jar: jar }); + } + + function execute_login(jar: request.CookieJar) { + return requestAsync.getAsync({ url: BASE_URL + Endpoints.FIRST_FACTOR_GET, jar: jar }); + } + + function execute_first_factor(jar: request.CookieJar) { + return requestAsync.postAsync({ + url: BASE_URL + Endpoints.FIRST_FACTOR_POST, + jar: jar, + form: { + username: "test_ok", + password: "password" + } + }); + } + + function execute_failing_first_factor(jar: request.CookieJar) { + return requestAsync.postAsync({ + url: BASE_URL + Endpoints.FIRST_FACTOR_POST, + jar: jar, + form: { + username: "test_nok", + password: "password" + } + }); + } + + return { + login: execute_login, + verify: execute_verification, + u2f_authentication: execute_u2f_authentication, + first_factor: execute_first_factor, + failing_first_factor: execute_failing_first_factor, + totp: execute_totp, + }; +}; + diff --git a/themes/black/server/tsconfig.json b/themes/black/server/tsconfig.json new file mode 100644 index 00000000..ebe98c5e --- /dev/null +++ b/themes/black/server/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es6", + "moduleResolution": "node", + "noImplicitAny": true, + "sourceMap": true, + "removeComments": true, + "outDir": "../dist", + "baseUrl": ".", + "paths": { + "*": [ + "./types/*", + "../shared/types/*" + ] + } + }, + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/themes/black/server/tslint.json b/themes/black/server/tslint.json new file mode 100644 index 00000000..c2c1b750 --- /dev/null +++ b/themes/black/server/tslint.json @@ -0,0 +1,60 @@ +{ + "rules": { + "class-name": true, + "comment-format": [ + true, + "check-space" + ], + "indent": [ + true, + "spaces" + ], + "one-line": [ + true, + "check-open-brace", + "check-whitespace" + ], + "no-var-keyword": true, + "quotemark": [ + true, + "double", + "avoid-escape" + ], + "semicolon": [ + true, + "always", + "ignore-bound-class-methods" + ], + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-operator", + "check-module", + "check-separator", + "check-type" + ], + "typedef-whitespace": [ + true, + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + }, + { + "call-signature": "onespace", + "index-signature": "onespace", + "parameter": "onespace", + "property-declaration": "onespace", + "variable-declaration": "onespace" + } + ], + "no-internal-module": true, + "no-trailing-whitespace": true, + "no-null-keyword": true, + "prefer-const": true, + "jsdoc-format": true + } +} diff --git a/themes/black/server/types/.directory b/themes/black/server/types/.directory new file mode 100644 index 00000000..1e65000e --- /dev/null +++ b/themes/black/server/types/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,17,20,58,27 +Version=3 +ViewMode=1 diff --git a/themes/black/server/types/AuthenticationSession.ts b/themes/black/server/types/AuthenticationSession.ts new file mode 100644 index 00000000..bbed0e71 --- /dev/null +++ b/themes/black/server/types/AuthenticationSession.ts @@ -0,0 +1,18 @@ +import U2f = require("u2f"); +import { Level } from "../src/lib/authentication/Level"; + +export interface AuthenticationSession { + userid: string; + authentication_level: Level; + keep_me_logged_in: boolean; + last_activity_datetime: number; + identity_check?: { + challenge: string; + userid: string; + }; + register_request?: U2f.Request; + sign_request?: U2f.Request; + email: string; + groups: string[]; + redirect?: string; +} \ No newline at end of file diff --git a/themes/black/server/types/Dependencies.ts b/themes/black/server/types/Dependencies.ts new file mode 100644 index 00000000..f20404db --- /dev/null +++ b/themes/black/server/types/Dependencies.ts @@ -0,0 +1,29 @@ +import winston = require("winston"); +import speakeasy = require("speakeasy"); +import nodemailer = require("nodemailer"); +import session = require("express-session"); +import nedb = require("nedb"); +import ldapjs = require("ldapjs"); +import u2f = require("u2f"); +import RedisSession = require("connect-redis"); +import Redis = require("redis"); + +export type Speakeasy = typeof speakeasy; +export type Winston = typeof winston; +export type Session = typeof session; +export type Nedb = typeof nedb; +export type Ldapjs = typeof ldapjs; +export type U2f = typeof u2f; +export type ConnectRedis = typeof RedisSession; +export type Redis = typeof Redis; + +export interface GlobalDependencies { + u2f: U2f; + ldapjs: Ldapjs; + session: Session; + Redis: Redis; + ConnectRedis: ConnectRedis; + winston: Winston; + speakeasy: Speakeasy; + nedb: Nedb; +} \ No newline at end of file diff --git a/themes/black/server/types/Identity.ts b/themes/black/server/types/Identity.ts new file mode 100644 index 00000000..e985984e --- /dev/null +++ b/themes/black/server/types/Identity.ts @@ -0,0 +1,6 @@ + + +export interface Identity { + userid: string; + email: string; +} \ No newline at end of file diff --git a/themes/black/server/types/TOTPSecret.ts b/themes/black/server/types/TOTPSecret.ts new file mode 100644 index 00000000..d6775f2f --- /dev/null +++ b/themes/black/server/types/TOTPSecret.ts @@ -0,0 +1,11 @@ + +export interface TOTPSecret { + ascii: string; + hex: string; + base32: string; + qr_code_ascii: string; + qr_code_hex: string; + qr_code_base32: string; + google_auth_qr: string; + otpauth_url: string; + } \ No newline at end of file diff --git a/themes/black/server/types/U2FRegistration.ts b/themes/black/server/types/U2FRegistration.ts new file mode 100644 index 00000000..b6080af0 --- /dev/null +++ b/themes/black/server/types/U2FRegistration.ts @@ -0,0 +1,5 @@ + +export interface U2FRegistration { + keyHandle: string; + publicKey: string; +} \ No newline at end of file diff --git a/themes/black/server/types/dovehash.d.ts b/themes/black/server/types/dovehash.d.ts new file mode 100644 index 00000000..c354609c --- /dev/null +++ b/themes/black/server/types/dovehash.d.ts @@ -0,0 +1,4 @@ + +declare module "dovehash" { + function encode(algo: string, text: string): string; +} \ No newline at end of file diff --git a/themes/black/server/types/speakeasy.d.ts b/themes/black/server/types/speakeasy.d.ts new file mode 100644 index 00000000..6ea06948 --- /dev/null +++ b/themes/black/server/types/speakeasy.d.ts @@ -0,0 +1,96 @@ +declare module "speakeasy" { + export = speakeasy + + interface SharedOptions { + encoding?: string + algorithm?: string + } + + interface DigestOptions extends SharedOptions { + secret: string + counter: number + } + + interface HOTPOptions extends SharedOptions { + secret: string + counter: number + digest?: Buffer + digits?: number + } + + interface HOTPVerifyOptions extends SharedOptions { + secret: string + token: string + counter: number + digits?: number + window?: number + } + + interface TOTPOptions extends SharedOptions { + secret: string + time?: number + step?: number + epoch?: number + counter?: number + digits?: number + } + + interface TOTPVerifyOptions extends SharedOptions { + secret: string + token: string + time?: number + step?: number + epoch?: number + counter?: number + digits?: number + window?: number + } + + interface GenerateSecretOptions { + length?: number + symbols?: boolean + otpauth_url?: boolean + name?: string + issuer?: string + } + + interface GeneratedSecret { + ascii: string + hex: string + base32: string + qr_code_ascii: string + qr_code_hex: string + qr_code_base32: string + google_auth_qr: string + otpauth_url: string + } + + interface OTPAuthURLOptions extends SharedOptions { + secret: string + label: string + type?: string + counter?: number + issuer?: string + digits?: number + period?: number + } + + interface Speakeasy { + digest: (options: DigestOptions) => Buffer + hotp: { + (options: HOTPOptions): string, + verifyDelta: (options: HOTPVerifyOptions) => boolean, + verify: (options: HOTPVerifyOptions) => boolean, + } + totp: { + (options: TOTPOptions): string + verifyDelta: (options: TOTPVerifyOptions) => boolean, + verify: (options: TOTPVerifyOptions) => boolean, + } + generateSecret: (options?: GenerateSecretOptions) => GeneratedSecret + generateSecretASCII: (length?: number, symbols?: boolean) => string + otpauthURL: (options: OTPAuthURLOptions) => string + } + + const speakeasy: Speakeasy +} \ No newline at end of file diff --git a/themes/main/server/src/views/layout/layout.pug b/themes/main/server/src/views/layout/layout.pug index 1d845be4..39d04504 100644 --- a/themes/main/server/src/views/layout/layout.pug +++ b/themes/main/server/src/views/layout/layout.pug @@ -13,7 +13,6 @@ html meta(http-equiv="refresh" content="4;url=" + redirection_url) body canvas#canvas(width='400', height='300') - script(src='/js/matrix.js') div(class="container") div(class="row") div(class="col-xs-12 col-sm-8 col-sm-offset-2 col-md-6 col-md-offset-3 col-lg-6 col-lg-offset-3")