From 1e71815b009e272087480d53c551112262e5e556 Mon Sep 17 00:00:00 2001 From: BankaiNoJutsu <lbegert@gmail.com> Date: Tue, 18 Dec 2018 08:32:04 +0100 Subject: [PATCH] added squares and triangles themes --- Gruntfile.js | 68 +- themes/squares/client/src/css/.directory | 4 + .../client/src/css/00-bootstrap.min.css | 5768 +++++++++++++++++ themes/squares/client/src/css/01-main.css | 77 + themes/squares/client/src/css/02-login.css | 136 + themes/squares/client/src/css/03-errors.css | 12 + .../client/src/css/03-password-reset-form.css | 4 + .../src/css/03-password-reset-request.css | 4 + .../client/src/css/03-totp-register.css | 22 + .../client/src/css/03-u2f-register.css | 5 + .../squares/client/src/img/LargeTriangles.svg | 1 + .../client/src/img/RandomizedPattern.svg | 1 + themes/squares/client/src/img/background.jpg | Bin 0 -> 587 bytes themes/squares/client/src/img/background.svg | 5 + themes/squares/client/src/img/icon.png | Bin 0 -> 1461 bytes themes/squares/client/src/img/mail.png | Bin 0 -> 3545 bytes .../client/src/img/matrix_circle_128x128.png | Bin 0 -> 35750 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/squares/client/src/img/padlock.png | Bin 0 -> 3265 bytes .../squares/client/src/img/password_white.png | Bin 0 -> 3858 bytes themes/squares/client/src/img/pendrive.png | Bin 0 -> 6721 bytes themes/squares/client/src/img/sharingan.png | Bin 0 -> 9213 bytes .../squares/client/src/img/stores/.directory | 4 + .../src/img/stores/applestore-badge.svg | 129 + .../src/img/stores/googleplay-badge.svg | 429 ++ themes/squares/client/src/img/success.png | Bin 0 -> 3147 bytes themes/squares/client/src/img/user.png | Bin 0 -> 2933 bytes themes/squares/client/src/img/warning.png | Bin 0 -> 4038 bytes themes/squares/client/src/index.ts | 34 + themes/squares/client/src/lib/GetPromised.ts | 14 + themes/squares/client/src/lib/INotifier.ts | 14 + themes/squares/client/src/lib/Notifier.ts | 83 + .../src/lib/QueryParametersRetriever.ts | 12 + themes/squares/client/src/lib/SafeRedirect.ts | 10 + .../lib/firstfactor/FirstFactorValidator.ts | 46 + .../client/src/lib/firstfactor/UISelectors.ts | 5 + .../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 + .../client/src/thirdparties/u2f-api.js | 749 +++ themes/squares/client/test/Notifier.test.ts | 71 + .../firstfactor/FirstFactorValidator.test.ts | 44 + .../squares/client/test/mocks/NotifierStub.ts | 33 + themes/squares/client/test/mocks/jquery.ts | 59 + themes/squares/client/test/mocks/u2f-api.ts | 14 + .../test/secondfactor/TOTPValidator.test.ts | 37 + .../test/totp-register/totp-register.test.ts | 31 + themes/squares/client/tsconfig.json | 24 + themes/squares/client/tslint.json | 60 + themes/squares/server/.directory | 4 + themes/squares/server/src/index.ts | 28 + themes/squares/server/src/lib/.directory | 4 + .../src/lib/AuthenticationSessionHandler.ts | 45 + themes/squares/server/src/lib/ErrorReplies.ts | 49 + themes/squares/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 + .../server/src/lib/IdentityValidable.ts | 19 + .../src/lib/IdentityValidableStub.spec.ts | 52 + themes/squares/server/src/lib/Server.spec.ts | 81 + themes/squares/server/src/lib/Server.ts | 93 + .../squares/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 + .../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 + .../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 + .../server/src/lib/stubs/ldapjs.spec.ts | 50 + .../server/src/lib/stubs/speakeasy.spec.ts | 7 + .../squares/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 + .../server/src/views/errors/.directory | 4 + .../squares/server/src/views/errors/401.pug | 16 + .../squares/server/src/views/errors/403.pug | 16 + .../squares/server/src/views/errors/404.pug | 11 + .../squares/server/src/views/firstfactor.pug | 23 + .../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 + .../squares/server/src/views/secondfactor.pug | 31 + .../server/src/views/totp-register.pug | 25 + .../squares/server/src/views/u2f-register.pug | 12 + themes/squares/server/test/requests.ts | 94 + themes/squares/server/tsconfig.json | 21 + themes/squares/server/tslint.json | 60 + themes/squares/server/types/.directory | 4 + .../server/types/AuthenticationSession.ts | 18 + themes/squares/server/types/Dependencies.ts | 29 + themes/squares/server/types/Identity.ts | 6 + themes/squares/server/types/TOTPSecret.ts | 11 + .../squares/server/types/U2FRegistration.ts | 5 + themes/squares/server/types/dovehash.d.ts | 4 + themes/squares/server/types/speakeasy.d.ts | 96 + themes/triangles/client/src/.directory | 4 + themes/triangles/client/src/css/.directory | 4 + .../client/src/css/00-bootstrap.min.css | 5768 +++++++++++++++++ themes/triangles/client/src/css/01-main.css | 77 + themes/triangles/client/src/css/02-login.css | 136 + themes/triangles/client/src/css/03-errors.css | 12 + .../client/src/css/03-password-reset-form.css | 4 + .../src/css/03-password-reset-request.css | 4 + .../client/src/css/03-totp-register.css | 22 + .../client/src/css/03-u2f-register.css | 5 + .../client/src/img/LargeTriangles.svg | 1 + .../triangles/client/src/img/background.jpg | Bin 0 -> 587 bytes themes/triangles/client/src/img/icon.png | Bin 0 -> 1461 bytes themes/triangles/client/src/img/mail.png | Bin 0 -> 3545 bytes .../client/src/img/matrix_circle_128x128.png | Bin 0 -> 35750 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/triangles/client/src/img/padlock.png | Bin 0 -> 3265 bytes .../client/src/img/password_white.png | Bin 0 -> 3858 bytes themes/triangles/client/src/img/pendrive.png | Bin 0 -> 6721 bytes themes/triangles/client/src/img/sharingan.png | Bin 0 -> 9213 bytes .../client/src/img/stores/.directory | 4 + .../src/img/stores/applestore-badge.svg | 129 + .../src/img/stores/googleplay-badge.svg | 429 ++ themes/triangles/client/src/img/success.png | Bin 0 -> 3147 bytes themes/triangles/client/src/img/user.png | Bin 0 -> 2933 bytes themes/triangles/client/src/img/warning.png | Bin 0 -> 4038 bytes themes/triangles/client/src/index.ts | 34 + .../triangles/client/src/lib/GetPromised.ts | 14 + themes/triangles/client/src/lib/INotifier.ts | 14 + themes/triangles/client/src/lib/Notifier.ts | 83 + .../src/lib/QueryParametersRetriever.ts | 12 + .../triangles/client/src/lib/SafeRedirect.ts | 10 + .../lib/firstfactor/FirstFactorValidator.ts | 46 + .../client/src/lib/firstfactor/UISelectors.ts | 5 + .../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 + .../client/src/thirdparties/u2f-api.js | 749 +++ themes/triangles/client/test/Notifier.test.ts | 71 + .../firstfactor/FirstFactorValidator.test.ts | 44 + .../client/test/mocks/NotifierStub.ts | 33 + themes/triangles/client/test/mocks/jquery.ts | 59 + themes/triangles/client/test/mocks/u2f-api.ts | 14 + .../test/secondfactor/TOTPValidator.test.ts | 37 + .../test/totp-register/totp-register.test.ts | 31 + themes/triangles/client/tsconfig.json | 24 + themes/triangles/client/tslint.json | 60 + themes/triangles/server/.directory | 4 + themes/triangles/server/src/index.ts | 28 + themes/triangles/server/src/lib/.directory | 4 + .../src/lib/AuthenticationSessionHandler.ts | 45 + .../triangles/server/src/lib/ErrorReplies.ts | 49 + themes/triangles/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 + .../server/src/lib/IdentityValidable.ts | 19 + .../src/lib/IdentityValidableStub.spec.ts | 52 + .../triangles/server/src/lib/Server.spec.ts | 81 + themes/triangles/server/src/lib/Server.ts | 93 + .../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 + .../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 + .../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 + .../server/src/lib/stubs/ldapjs.spec.ts | 50 + .../server/src/lib/stubs/speakeasy.spec.ts | 7 + .../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 + .../server/src/views/errors/.directory | 4 + .../triangles/server/src/views/errors/401.pug | 16 + .../triangles/server/src/views/errors/403.pug | 16 + .../triangles/server/src/views/errors/404.pug | 11 + .../server/src/views/firstfactor.pug | 23 + .../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 + .../server/src/views/secondfactor.pug | 31 + .../server/src/views/totp-register.pug | 25 + .../server/src/views/u2f-register.pug | 12 + themes/triangles/server/test/requests.ts | 94 + themes/triangles/server/tsconfig.json | 21 + themes/triangles/server/tslint.json | 60 + themes/triangles/server/types/.directory | 4 + .../server/types/AuthenticationSession.ts | 18 + themes/triangles/server/types/Dependencies.ts | 29 + themes/triangles/server/types/Identity.ts | 6 + themes/triangles/server/types/TOTPSecret.ts | 11 + .../triangles/server/types/U2FRegistration.ts | 5 + themes/triangles/server/types/dovehash.d.ts | 4 + themes/triangles/server/types/speakeasy.d.ts | 96 + 550 files changed, 37963 insertions(+), 3 deletions(-) create mode 100644 themes/squares/client/src/css/.directory create mode 100644 themes/squares/client/src/css/00-bootstrap.min.css create mode 100644 themes/squares/client/src/css/01-main.css create mode 100644 themes/squares/client/src/css/02-login.css create mode 100644 themes/squares/client/src/css/03-errors.css create mode 100644 themes/squares/client/src/css/03-password-reset-form.css create mode 100644 themes/squares/client/src/css/03-password-reset-request.css create mode 100644 themes/squares/client/src/css/03-totp-register.css create mode 100644 themes/squares/client/src/css/03-u2f-register.css create mode 100644 themes/squares/client/src/img/LargeTriangles.svg create mode 100644 themes/squares/client/src/img/RandomizedPattern.svg create mode 100644 themes/squares/client/src/img/background.jpg create mode 100644 themes/squares/client/src/img/background.svg create mode 100644 themes/squares/client/src/img/icon.png create mode 100644 themes/squares/client/src/img/mail.png create mode 100644 themes/squares/client/src/img/matrix_circle_128x128.png create mode 100644 themes/squares/client/src/img/notifications/.directory create mode 100644 themes/squares/client/src/img/notifications/error.png create mode 100644 themes/squares/client/src/img/notifications/info.png create mode 100644 themes/squares/client/src/img/notifications/success.png create mode 100644 themes/squares/client/src/img/notifications/warning.png create mode 100644 themes/squares/client/src/img/padlock.png create mode 100644 themes/squares/client/src/img/password_white.png create mode 100644 themes/squares/client/src/img/pendrive.png create mode 100644 themes/squares/client/src/img/sharingan.png create mode 100644 themes/squares/client/src/img/stores/.directory create mode 100644 themes/squares/client/src/img/stores/applestore-badge.svg create mode 100644 themes/squares/client/src/img/stores/googleplay-badge.svg create mode 100644 themes/squares/client/src/img/success.png create mode 100644 themes/squares/client/src/img/user.png create mode 100644 themes/squares/client/src/img/warning.png create mode 100644 themes/squares/client/src/index.ts create mode 100644 themes/squares/client/src/lib/GetPromised.ts create mode 100644 themes/squares/client/src/lib/INotifier.ts create mode 100644 themes/squares/client/src/lib/Notifier.ts create mode 100644 themes/squares/client/src/lib/QueryParametersRetriever.ts create mode 100644 themes/squares/client/src/lib/SafeRedirect.ts create mode 100644 themes/squares/client/src/lib/firstfactor/FirstFactorValidator.ts create mode 100644 themes/squares/client/src/lib/firstfactor/UISelectors.ts create mode 100644 themes/squares/client/src/lib/firstfactor/index.ts create mode 100644 themes/squares/client/src/lib/reset-password/constants.ts create mode 100644 themes/squares/client/src/lib/reset-password/reset-password-form.ts create mode 100644 themes/squares/client/src/lib/reset-password/reset-password-request.ts create mode 100644 themes/squares/client/src/lib/secondfactor/TOTPValidator.ts create mode 100644 themes/squares/client/src/lib/secondfactor/U2FValidator.ts create mode 100644 themes/squares/client/src/lib/secondfactor/constants.ts create mode 100644 themes/squares/client/src/lib/secondfactor/index.ts create mode 100644 themes/squares/client/src/lib/totp-register/totp-register.ts create mode 100644 themes/squares/client/src/lib/totp-register/ui-selector.ts create mode 100644 themes/squares/client/src/lib/u2f-register/u2f-register.ts create mode 100644 themes/squares/client/src/thirdparties/qrcode.min.js create mode 100644 themes/squares/client/src/thirdparties/u2f-api.js create mode 100644 themes/squares/client/test/Notifier.test.ts create mode 100644 themes/squares/client/test/firstfactor/FirstFactorValidator.test.ts create mode 100644 themes/squares/client/test/mocks/NotifierStub.ts create mode 100644 themes/squares/client/test/mocks/jquery.ts create mode 100644 themes/squares/client/test/mocks/u2f-api.ts create mode 100644 themes/squares/client/test/secondfactor/TOTPValidator.test.ts create mode 100644 themes/squares/client/test/totp-register/totp-register.test.ts create mode 100644 themes/squares/client/tsconfig.json create mode 100644 themes/squares/client/tslint.json create mode 100644 themes/squares/server/.directory create mode 100755 themes/squares/server/src/index.ts create mode 100644 themes/squares/server/src/lib/.directory create mode 100644 themes/squares/server/src/lib/AuthenticationSessionHandler.ts create mode 100644 themes/squares/server/src/lib/ErrorReplies.ts create mode 100644 themes/squares/server/src/lib/Exceptions.ts create mode 100644 themes/squares/server/src/lib/FirstFactorValidator.ts create mode 100644 themes/squares/server/src/lib/IdentityCheckMiddleware.spec.ts create mode 100644 themes/squares/server/src/lib/IdentityCheckMiddleware.ts create mode 100644 themes/squares/server/src/lib/IdentityCheckPreValidationTemplate.ts create mode 100644 themes/squares/server/src/lib/IdentityValidable.ts create mode 100644 themes/squares/server/src/lib/IdentityValidableStub.spec.ts create mode 100644 themes/squares/server/src/lib/Server.spec.ts create mode 100644 themes/squares/server/src/lib/Server.ts create mode 100644 themes/squares/server/src/lib/ServerVariables.ts create mode 100644 themes/squares/server/src/lib/ServerVariablesInitializer.ts create mode 100644 themes/squares/server/src/lib/ServerVariablesMockBuilder.spec.ts create mode 100644 themes/squares/server/src/lib/authentication/Level.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/GroupsAndEmails.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/IUsersDatabase.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/IUsersDatabaseStub.spec.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/file/FileUsersDatabase.spec.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/file/FileUsersDatabase.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/file/ReadWriteQueue.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/ISession.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/ISessionFactory.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.spec.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/SafeSession.spec.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/SafeSession.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/Sanitizer.spec.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/Sanitizer.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/Session.spec.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/Session.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/SessionFactory.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/SessionFactoryStub.spec.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/SessionStub.spec.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/connector/Connector.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/connector/ConnectorFactory.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/connector/ConnectorFactoryStub.spec.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/connector/ConnectorStub.spec.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/connector/IConnector.ts create mode 100644 themes/squares/server/src/lib/authentication/backends/ldap/connector/IConnectorFactory.ts create mode 100644 themes/squares/server/src/lib/authentication/totp/ITotpHandler.ts create mode 100644 themes/squares/server/src/lib/authentication/totp/TotpHandler.spec.ts create mode 100644 themes/squares/server/src/lib/authentication/totp/TotpHandler.ts create mode 100644 themes/squares/server/src/lib/authentication/totp/TotpHandlerStub.spec.ts create mode 100644 themes/squares/server/src/lib/authentication/u2f/IU2fHandler.ts create mode 100644 themes/squares/server/src/lib/authentication/u2f/U2fHandler.ts create mode 100644 themes/squares/server/src/lib/authentication/u2f/U2fHandlerStub.spec.ts create mode 100644 themes/squares/server/src/lib/authorization/Authorizer.spec.ts create mode 100644 themes/squares/server/src/lib/authorization/Authorizer.ts create mode 100644 themes/squares/server/src/lib/authorization/AuthorizerStub.spec.ts create mode 100644 themes/squares/server/src/lib/authorization/IAuthorizer.ts create mode 100644 themes/squares/server/src/lib/authorization/Level.ts create mode 100644 themes/squares/server/src/lib/authorization/MultipleDomainMatcher.ts create mode 100644 themes/squares/server/src/lib/authorization/Object.ts create mode 100644 themes/squares/server/src/lib/authorization/Subject.ts create mode 100644 themes/squares/server/src/lib/configuration/ConfigurationParser.spec.ts create mode 100644 themes/squares/server/src/lib/configuration/ConfigurationParser.ts create mode 100644 themes/squares/server/src/lib/configuration/SessionConfigurationBuilder.spec.ts create mode 100644 themes/squares/server/src/lib/configuration/SessionConfigurationBuilder.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/AclConfiguration.spec.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/AclConfiguration.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.spec.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/Configuration.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/FileUsersDatabaseConfiguration.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/LdapConfiguration.spec.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/LdapConfiguration.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/NotifierConfiguration.spec.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/NotifierConfiguration.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/RegulationConfiguration.spec.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/RegulationConfiguration.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/SessionConfiguration.spec.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/SessionConfiguration.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/StorageConfiguration.spec.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/StorageConfiguration.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/TotpConfiguration.ts create mode 100644 themes/squares/server/src/lib/configuration/schema/UserDatabaseConfiguration.ts create mode 100644 themes/squares/server/src/lib/connectors/mongo/IMongoClient.d.ts create mode 100644 themes/squares/server/src/lib/connectors/mongo/MongoClient.spec.ts create mode 100644 themes/squares/server/src/lib/connectors/mongo/MongoClient.ts create mode 100644 themes/squares/server/src/lib/connectors/mongo/MongoClientStub.spec.ts create mode 100644 themes/squares/server/src/lib/logging/GlobalLogger.ts create mode 100644 themes/squares/server/src/lib/logging/GlobalLoggerStub.spec.ts create mode 100644 themes/squares/server/src/lib/logging/IGlobalLogger.ts create mode 100644 themes/squares/server/src/lib/logging/IRequestLogger.ts create mode 100644 themes/squares/server/src/lib/logging/RequestLogger.ts create mode 100644 themes/squares/server/src/lib/logging/RequestLoggerStub.spec.ts create mode 100644 themes/squares/server/src/lib/notifiers/AbstractEmailNotifier.ts create mode 100644 themes/squares/server/src/lib/notifiers/EmailNotifier.spec.ts create mode 100644 themes/squares/server/src/lib/notifiers/EmailNotifier.ts create mode 100644 themes/squares/server/src/lib/notifiers/FileSystemNotifier.ts create mode 100644 themes/squares/server/src/lib/notifiers/IMailSender.ts create mode 100644 themes/squares/server/src/lib/notifiers/IMailSenderBuilder.ts create mode 100644 themes/squares/server/src/lib/notifiers/INotifier.ts create mode 100644 themes/squares/server/src/lib/notifiers/MailSender.ts create mode 100644 themes/squares/server/src/lib/notifiers/MailSenderBuilder.spec.ts create mode 100644 themes/squares/server/src/lib/notifiers/MailSenderBuilder.ts create mode 100644 themes/squares/server/src/lib/notifiers/MailSenderBuilderStub.spec.ts create mode 100644 themes/squares/server/src/lib/notifiers/MailSenderStub.spec.ts create mode 100644 themes/squares/server/src/lib/notifiers/NotifierFactory.spec.ts create mode 100644 themes/squares/server/src/lib/notifiers/NotifierFactory.ts create mode 100644 themes/squares/server/src/lib/notifiers/NotifierStub.spec.ts create mode 100644 themes/squares/server/src/lib/notifiers/SmtpNotifier.ts create mode 100644 themes/squares/server/src/lib/regulation/IRegulator.ts create mode 100644 themes/squares/server/src/lib/regulation/Regulator.spec.ts create mode 100644 themes/squares/server/src/lib/regulation/Regulator.ts create mode 100644 themes/squares/server/src/lib/regulation/RegulatorStub.spec.ts create mode 100644 themes/squares/server/src/lib/routes/error/401/get.spec.ts create mode 100644 themes/squares/server/src/lib/routes/error/401/get.ts create mode 100644 themes/squares/server/src/lib/routes/error/403/get.spec.ts create mode 100644 themes/squares/server/src/lib/routes/error/403/get.ts create mode 100644 themes/squares/server/src/lib/routes/error/404/get.spec.ts create mode 100644 themes/squares/server/src/lib/routes/error/404/get.ts create mode 100644 themes/squares/server/src/lib/routes/error/redirector.ts create mode 100644 themes/squares/server/src/lib/routes/firstfactor/get.ts create mode 100644 themes/squares/server/src/lib/routes/firstfactor/post.spec.ts create mode 100644 themes/squares/server/src/lib/routes/firstfactor/post.ts create mode 100644 themes/squares/server/src/lib/routes/loggedin/get.ts create mode 100644 themes/squares/server/src/lib/routes/logout/get.ts create mode 100644 themes/squares/server/src/lib/routes/password-reset/constants.ts create mode 100644 themes/squares/server/src/lib/routes/password-reset/form/post.spec.ts create mode 100644 themes/squares/server/src/lib/routes/password-reset/form/post.ts create mode 100644 themes/squares/server/src/lib/routes/password-reset/identity/PasswordResetHandler.spec.ts create mode 100644 themes/squares/server/src/lib/routes/password-reset/identity/PasswordResetHandler.ts create mode 100644 themes/squares/server/src/lib/routes/password-reset/request/get.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/get.spec.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/get.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/redirect.spec.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/redirect.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/totp/constants.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.spec.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/totp/sign/post.spec.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/totp/sign/post.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/u2f/U2FCommon.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.spec.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/u2f/register/post.spec.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/u2f/register/post.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/u2f/register_request/get.spec.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/u2f/register_request/get.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/u2f/sign/post.spec.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/u2f/sign/post.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/u2f/sign_request/get.spec.ts create mode 100644 themes/squares/server/src/lib/routes/secondfactor/u2f/sign_request/get.ts create mode 100644 themes/squares/server/src/lib/routes/verify/access_control.ts create mode 100644 themes/squares/server/src/lib/routes/verify/get.spec.ts create mode 100644 themes/squares/server/src/lib/routes/verify/get.ts create mode 100644 themes/squares/server/src/lib/routes/verify/get_basic_auth.ts create mode 100644 themes/squares/server/src/lib/routes/verify/get_session_cookie.ts create mode 100644 themes/squares/server/src/lib/storage/AuthenticationTraceDocument.d.ts create mode 100644 themes/squares/server/src/lib/storage/CollectionFactoryFactory.ts create mode 100644 themes/squares/server/src/lib/storage/CollectionFactoryStub.spec.ts create mode 100644 themes/squares/server/src/lib/storage/CollectionStub.spec.ts create mode 100644 themes/squares/server/src/lib/storage/ICollection.d.ts create mode 100644 themes/squares/server/src/lib/storage/ICollectionFactory.d.ts create mode 100644 themes/squares/server/src/lib/storage/IUserDataStore.d.ts create mode 100644 themes/squares/server/src/lib/storage/IdentityValidationDocument.d.ts create mode 100644 themes/squares/server/src/lib/storage/TOTPSecretDocument.d.ts create mode 100644 themes/squares/server/src/lib/storage/U2FRegistrationDocument.d.ts create mode 100644 themes/squares/server/src/lib/storage/UserDataStore.spec.ts create mode 100644 themes/squares/server/src/lib/storage/UserDataStore.ts create mode 100644 themes/squares/server/src/lib/storage/UserDataStoreStub.spec.ts create mode 100644 themes/squares/server/src/lib/storage/mongo/MongoCollection.spec.ts create mode 100644 themes/squares/server/src/lib/storage/mongo/MongoCollection.ts create mode 100644 themes/squares/server/src/lib/storage/mongo/MongoCollectionFactory.spec.ts create mode 100644 themes/squares/server/src/lib/storage/mongo/MongoCollectionFactory.ts create mode 100644 themes/squares/server/src/lib/storage/nedb/NedbCollection.spec.ts create mode 100644 themes/squares/server/src/lib/storage/nedb/NedbCollection.ts create mode 100644 themes/squares/server/src/lib/storage/nedb/NedbCollectionFactory.spec.ts create mode 100644 themes/squares/server/src/lib/storage/nedb/NedbCollectionFactory.ts create mode 100644 themes/squares/server/src/lib/stubs/express.spec.ts create mode 100644 themes/squares/server/src/lib/stubs/ldapjs.spec.ts create mode 100644 themes/squares/server/src/lib/stubs/speakeasy.spec.ts create mode 100644 themes/squares/server/src/lib/stubs/u2f.spec.ts create mode 100644 themes/squares/server/src/lib/utils/HashGenerator.spec.ts create mode 100644 themes/squares/server/src/lib/utils/HashGenerator.ts create mode 100644 themes/squares/server/src/lib/utils/ObjectCloner.ts create mode 100644 themes/squares/server/src/lib/utils/SafeRedirection.spec.ts create mode 100644 themes/squares/server/src/lib/utils/SafeRedirection.ts create mode 100644 themes/squares/server/src/lib/utils/URLDecomposer.spec.ts create mode 100644 themes/squares/server/src/lib/utils/URLDecomposer.ts create mode 100644 themes/squares/server/src/lib/web_server/Configurator.ts create mode 100644 themes/squares/server/src/lib/web_server/RestApi.ts create mode 100644 themes/squares/server/src/lib/web_server/middlewares/RequireValidatedFirstFactor.ts create mode 100644 themes/squares/server/src/lib/web_server/middlewares/WithHeadersLogged.ts create mode 100644 themes/squares/server/src/resources/email-template.ejs create mode 100644 themes/squares/server/src/views/already-logged-in.pug create mode 100644 themes/squares/server/src/views/errors/.directory create mode 100644 themes/squares/server/src/views/errors/401.pug create mode 100644 themes/squares/server/src/views/errors/403.pug create mode 100644 themes/squares/server/src/views/errors/404.pug create mode 100644 themes/squares/server/src/views/firstfactor.pug create mode 100644 themes/squares/server/src/views/layout/layout.pug create mode 100644 themes/squares/server/src/views/need-identity-validation.pug create mode 100644 themes/squares/server/src/views/password-reset-form.pug create mode 100644 themes/squares/server/src/views/password-reset-request.pug create mode 100644 themes/squares/server/src/views/secondfactor.pug create mode 100644 themes/squares/server/src/views/totp-register.pug create mode 100644 themes/squares/server/src/views/u2f-register.pug create mode 100644 themes/squares/server/test/requests.ts create mode 100644 themes/squares/server/tsconfig.json create mode 100644 themes/squares/server/tslint.json create mode 100644 themes/squares/server/types/.directory create mode 100644 themes/squares/server/types/AuthenticationSession.ts create mode 100644 themes/squares/server/types/Dependencies.ts create mode 100644 themes/squares/server/types/Identity.ts create mode 100644 themes/squares/server/types/TOTPSecret.ts create mode 100644 themes/squares/server/types/U2FRegistration.ts create mode 100644 themes/squares/server/types/dovehash.d.ts create mode 100644 themes/squares/server/types/speakeasy.d.ts create mode 100644 themes/triangles/client/src/.directory create mode 100644 themes/triangles/client/src/css/.directory create mode 100644 themes/triangles/client/src/css/00-bootstrap.min.css create mode 100644 themes/triangles/client/src/css/01-main.css create mode 100644 themes/triangles/client/src/css/02-login.css create mode 100644 themes/triangles/client/src/css/03-errors.css create mode 100644 themes/triangles/client/src/css/03-password-reset-form.css create mode 100644 themes/triangles/client/src/css/03-password-reset-request.css create mode 100644 themes/triangles/client/src/css/03-totp-register.css create mode 100644 themes/triangles/client/src/css/03-u2f-register.css create mode 100644 themes/triangles/client/src/img/LargeTriangles.svg create mode 100644 themes/triangles/client/src/img/background.jpg create mode 100644 themes/triangles/client/src/img/icon.png create mode 100644 themes/triangles/client/src/img/mail.png create mode 100644 themes/triangles/client/src/img/matrix_circle_128x128.png create mode 100644 themes/triangles/client/src/img/notifications/.directory create mode 100644 themes/triangles/client/src/img/notifications/error.png create mode 100644 themes/triangles/client/src/img/notifications/info.png create mode 100644 themes/triangles/client/src/img/notifications/success.png create mode 100644 themes/triangles/client/src/img/notifications/warning.png create mode 100644 themes/triangles/client/src/img/padlock.png create mode 100644 themes/triangles/client/src/img/password_white.png create mode 100644 themes/triangles/client/src/img/pendrive.png create mode 100644 themes/triangles/client/src/img/sharingan.png create mode 100644 themes/triangles/client/src/img/stores/.directory create mode 100644 themes/triangles/client/src/img/stores/applestore-badge.svg create mode 100644 themes/triangles/client/src/img/stores/googleplay-badge.svg create mode 100644 themes/triangles/client/src/img/success.png create mode 100644 themes/triangles/client/src/img/user.png create mode 100644 themes/triangles/client/src/img/warning.png create mode 100644 themes/triangles/client/src/index.ts create mode 100644 themes/triangles/client/src/lib/GetPromised.ts create mode 100644 themes/triangles/client/src/lib/INotifier.ts create mode 100644 themes/triangles/client/src/lib/Notifier.ts create mode 100644 themes/triangles/client/src/lib/QueryParametersRetriever.ts create mode 100644 themes/triangles/client/src/lib/SafeRedirect.ts create mode 100644 themes/triangles/client/src/lib/firstfactor/FirstFactorValidator.ts create mode 100644 themes/triangles/client/src/lib/firstfactor/UISelectors.ts create mode 100644 themes/triangles/client/src/lib/firstfactor/index.ts create mode 100644 themes/triangles/client/src/lib/reset-password/constants.ts create mode 100644 themes/triangles/client/src/lib/reset-password/reset-password-form.ts create mode 100644 themes/triangles/client/src/lib/reset-password/reset-password-request.ts create mode 100644 themes/triangles/client/src/lib/secondfactor/TOTPValidator.ts create mode 100644 themes/triangles/client/src/lib/secondfactor/U2FValidator.ts create mode 100644 themes/triangles/client/src/lib/secondfactor/constants.ts create mode 100644 themes/triangles/client/src/lib/secondfactor/index.ts create mode 100644 themes/triangles/client/src/lib/totp-register/totp-register.ts create mode 100644 themes/triangles/client/src/lib/totp-register/ui-selector.ts create mode 100644 themes/triangles/client/src/lib/u2f-register/u2f-register.ts create mode 100644 themes/triangles/client/src/thirdparties/qrcode.min.js create mode 100644 themes/triangles/client/src/thirdparties/u2f-api.js create mode 100644 themes/triangles/client/test/Notifier.test.ts create mode 100644 themes/triangles/client/test/firstfactor/FirstFactorValidator.test.ts create mode 100644 themes/triangles/client/test/mocks/NotifierStub.ts create mode 100644 themes/triangles/client/test/mocks/jquery.ts create mode 100644 themes/triangles/client/test/mocks/u2f-api.ts create mode 100644 themes/triangles/client/test/secondfactor/TOTPValidator.test.ts create mode 100644 themes/triangles/client/test/totp-register/totp-register.test.ts create mode 100644 themes/triangles/client/tsconfig.json create mode 100644 themes/triangles/client/tslint.json create mode 100644 themes/triangles/server/.directory create mode 100755 themes/triangles/server/src/index.ts create mode 100644 themes/triangles/server/src/lib/.directory create mode 100644 themes/triangles/server/src/lib/AuthenticationSessionHandler.ts create mode 100644 themes/triangles/server/src/lib/ErrorReplies.ts create mode 100644 themes/triangles/server/src/lib/Exceptions.ts create mode 100644 themes/triangles/server/src/lib/FirstFactorValidator.ts create mode 100644 themes/triangles/server/src/lib/IdentityCheckMiddleware.spec.ts create mode 100644 themes/triangles/server/src/lib/IdentityCheckMiddleware.ts create mode 100644 themes/triangles/server/src/lib/IdentityCheckPreValidationTemplate.ts create mode 100644 themes/triangles/server/src/lib/IdentityValidable.ts create mode 100644 themes/triangles/server/src/lib/IdentityValidableStub.spec.ts create mode 100644 themes/triangles/server/src/lib/Server.spec.ts create mode 100644 themes/triangles/server/src/lib/Server.ts create mode 100644 themes/triangles/server/src/lib/ServerVariables.ts create mode 100644 themes/triangles/server/src/lib/ServerVariablesInitializer.ts create mode 100644 themes/triangles/server/src/lib/ServerVariablesMockBuilder.spec.ts create mode 100644 themes/triangles/server/src/lib/authentication/Level.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/GroupsAndEmails.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/IUsersDatabase.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/IUsersDatabaseStub.spec.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/file/FileUsersDatabase.spec.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/file/FileUsersDatabase.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/file/ReadWriteQueue.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/ISession.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/ISessionFactory.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.spec.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/SafeSession.spec.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/SafeSession.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/Sanitizer.spec.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/Sanitizer.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/Session.spec.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/Session.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/SessionFactory.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/SessionFactoryStub.spec.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/SessionStub.spec.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/connector/Connector.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/connector/ConnectorFactory.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/connector/ConnectorFactoryStub.spec.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/connector/ConnectorStub.spec.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/connector/IConnector.ts create mode 100644 themes/triangles/server/src/lib/authentication/backends/ldap/connector/IConnectorFactory.ts create mode 100644 themes/triangles/server/src/lib/authentication/totp/ITotpHandler.ts create mode 100644 themes/triangles/server/src/lib/authentication/totp/TotpHandler.spec.ts create mode 100644 themes/triangles/server/src/lib/authentication/totp/TotpHandler.ts create mode 100644 themes/triangles/server/src/lib/authentication/totp/TotpHandlerStub.spec.ts create mode 100644 themes/triangles/server/src/lib/authentication/u2f/IU2fHandler.ts create mode 100644 themes/triangles/server/src/lib/authentication/u2f/U2fHandler.ts create mode 100644 themes/triangles/server/src/lib/authentication/u2f/U2fHandlerStub.spec.ts create mode 100644 themes/triangles/server/src/lib/authorization/Authorizer.spec.ts create mode 100644 themes/triangles/server/src/lib/authorization/Authorizer.ts create mode 100644 themes/triangles/server/src/lib/authorization/AuthorizerStub.spec.ts create mode 100644 themes/triangles/server/src/lib/authorization/IAuthorizer.ts create mode 100644 themes/triangles/server/src/lib/authorization/Level.ts create mode 100644 themes/triangles/server/src/lib/authorization/MultipleDomainMatcher.ts create mode 100644 themes/triangles/server/src/lib/authorization/Object.ts create mode 100644 themes/triangles/server/src/lib/authorization/Subject.ts create mode 100644 themes/triangles/server/src/lib/configuration/ConfigurationParser.spec.ts create mode 100644 themes/triangles/server/src/lib/configuration/ConfigurationParser.ts create mode 100644 themes/triangles/server/src/lib/configuration/SessionConfigurationBuilder.spec.ts create mode 100644 themes/triangles/server/src/lib/configuration/SessionConfigurationBuilder.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/AclConfiguration.spec.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/AclConfiguration.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.spec.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/Configuration.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/FileUsersDatabaseConfiguration.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/LdapConfiguration.spec.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/LdapConfiguration.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/NotifierConfiguration.spec.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/NotifierConfiguration.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/RegulationConfiguration.spec.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/RegulationConfiguration.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/SessionConfiguration.spec.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/SessionConfiguration.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/StorageConfiguration.spec.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/StorageConfiguration.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/TotpConfiguration.ts create mode 100644 themes/triangles/server/src/lib/configuration/schema/UserDatabaseConfiguration.ts create mode 100644 themes/triangles/server/src/lib/connectors/mongo/IMongoClient.d.ts create mode 100644 themes/triangles/server/src/lib/connectors/mongo/MongoClient.spec.ts create mode 100644 themes/triangles/server/src/lib/connectors/mongo/MongoClient.ts create mode 100644 themes/triangles/server/src/lib/connectors/mongo/MongoClientStub.spec.ts create mode 100644 themes/triangles/server/src/lib/logging/GlobalLogger.ts create mode 100644 themes/triangles/server/src/lib/logging/GlobalLoggerStub.spec.ts create mode 100644 themes/triangles/server/src/lib/logging/IGlobalLogger.ts create mode 100644 themes/triangles/server/src/lib/logging/IRequestLogger.ts create mode 100644 themes/triangles/server/src/lib/logging/RequestLogger.ts create mode 100644 themes/triangles/server/src/lib/logging/RequestLoggerStub.spec.ts create mode 100644 themes/triangles/server/src/lib/notifiers/AbstractEmailNotifier.ts create mode 100644 themes/triangles/server/src/lib/notifiers/EmailNotifier.spec.ts create mode 100644 themes/triangles/server/src/lib/notifiers/EmailNotifier.ts create mode 100644 themes/triangles/server/src/lib/notifiers/FileSystemNotifier.ts create mode 100644 themes/triangles/server/src/lib/notifiers/IMailSender.ts create mode 100644 themes/triangles/server/src/lib/notifiers/IMailSenderBuilder.ts create mode 100644 themes/triangles/server/src/lib/notifiers/INotifier.ts create mode 100644 themes/triangles/server/src/lib/notifiers/MailSender.ts create mode 100644 themes/triangles/server/src/lib/notifiers/MailSenderBuilder.spec.ts create mode 100644 themes/triangles/server/src/lib/notifiers/MailSenderBuilder.ts create mode 100644 themes/triangles/server/src/lib/notifiers/MailSenderBuilderStub.spec.ts create mode 100644 themes/triangles/server/src/lib/notifiers/MailSenderStub.spec.ts create mode 100644 themes/triangles/server/src/lib/notifiers/NotifierFactory.spec.ts create mode 100644 themes/triangles/server/src/lib/notifiers/NotifierFactory.ts create mode 100644 themes/triangles/server/src/lib/notifiers/NotifierStub.spec.ts create mode 100644 themes/triangles/server/src/lib/notifiers/SmtpNotifier.ts create mode 100644 themes/triangles/server/src/lib/regulation/IRegulator.ts create mode 100644 themes/triangles/server/src/lib/regulation/Regulator.spec.ts create mode 100644 themes/triangles/server/src/lib/regulation/Regulator.ts create mode 100644 themes/triangles/server/src/lib/regulation/RegulatorStub.spec.ts create mode 100644 themes/triangles/server/src/lib/routes/error/401/get.spec.ts create mode 100644 themes/triangles/server/src/lib/routes/error/401/get.ts create mode 100644 themes/triangles/server/src/lib/routes/error/403/get.spec.ts create mode 100644 themes/triangles/server/src/lib/routes/error/403/get.ts create mode 100644 themes/triangles/server/src/lib/routes/error/404/get.spec.ts create mode 100644 themes/triangles/server/src/lib/routes/error/404/get.ts create mode 100644 themes/triangles/server/src/lib/routes/error/redirector.ts create mode 100644 themes/triangles/server/src/lib/routes/firstfactor/get.ts create mode 100644 themes/triangles/server/src/lib/routes/firstfactor/post.spec.ts create mode 100644 themes/triangles/server/src/lib/routes/firstfactor/post.ts create mode 100644 themes/triangles/server/src/lib/routes/loggedin/get.ts create mode 100644 themes/triangles/server/src/lib/routes/logout/get.ts create mode 100644 themes/triangles/server/src/lib/routes/password-reset/constants.ts create mode 100644 themes/triangles/server/src/lib/routes/password-reset/form/post.spec.ts create mode 100644 themes/triangles/server/src/lib/routes/password-reset/form/post.ts create mode 100644 themes/triangles/server/src/lib/routes/password-reset/identity/PasswordResetHandler.spec.ts create mode 100644 themes/triangles/server/src/lib/routes/password-reset/identity/PasswordResetHandler.ts create mode 100644 themes/triangles/server/src/lib/routes/password-reset/request/get.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/get.spec.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/get.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/redirect.spec.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/redirect.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/totp/constants.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.spec.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/totp/sign/post.spec.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/totp/sign/post.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/u2f/U2FCommon.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.spec.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/u2f/register/post.spec.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/u2f/register/post.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/u2f/register_request/get.spec.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/u2f/register_request/get.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/u2f/sign/post.spec.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/u2f/sign/post.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/u2f/sign_request/get.spec.ts create mode 100644 themes/triangles/server/src/lib/routes/secondfactor/u2f/sign_request/get.ts create mode 100644 themes/triangles/server/src/lib/routes/verify/access_control.ts create mode 100644 themes/triangles/server/src/lib/routes/verify/get.spec.ts create mode 100644 themes/triangles/server/src/lib/routes/verify/get.ts create mode 100644 themes/triangles/server/src/lib/routes/verify/get_basic_auth.ts create mode 100644 themes/triangles/server/src/lib/routes/verify/get_session_cookie.ts create mode 100644 themes/triangles/server/src/lib/storage/AuthenticationTraceDocument.d.ts create mode 100644 themes/triangles/server/src/lib/storage/CollectionFactoryFactory.ts create mode 100644 themes/triangles/server/src/lib/storage/CollectionFactoryStub.spec.ts create mode 100644 themes/triangles/server/src/lib/storage/CollectionStub.spec.ts create mode 100644 themes/triangles/server/src/lib/storage/ICollection.d.ts create mode 100644 themes/triangles/server/src/lib/storage/ICollectionFactory.d.ts create mode 100644 themes/triangles/server/src/lib/storage/IUserDataStore.d.ts create mode 100644 themes/triangles/server/src/lib/storage/IdentityValidationDocument.d.ts create mode 100644 themes/triangles/server/src/lib/storage/TOTPSecretDocument.d.ts create mode 100644 themes/triangles/server/src/lib/storage/U2FRegistrationDocument.d.ts create mode 100644 themes/triangles/server/src/lib/storage/UserDataStore.spec.ts create mode 100644 themes/triangles/server/src/lib/storage/UserDataStore.ts create mode 100644 themes/triangles/server/src/lib/storage/UserDataStoreStub.spec.ts create mode 100644 themes/triangles/server/src/lib/storage/mongo/MongoCollection.spec.ts create mode 100644 themes/triangles/server/src/lib/storage/mongo/MongoCollection.ts create mode 100644 themes/triangles/server/src/lib/storage/mongo/MongoCollectionFactory.spec.ts create mode 100644 themes/triangles/server/src/lib/storage/mongo/MongoCollectionFactory.ts create mode 100644 themes/triangles/server/src/lib/storage/nedb/NedbCollection.spec.ts create mode 100644 themes/triangles/server/src/lib/storage/nedb/NedbCollection.ts create mode 100644 themes/triangles/server/src/lib/storage/nedb/NedbCollectionFactory.spec.ts create mode 100644 themes/triangles/server/src/lib/storage/nedb/NedbCollectionFactory.ts create mode 100644 themes/triangles/server/src/lib/stubs/express.spec.ts create mode 100644 themes/triangles/server/src/lib/stubs/ldapjs.spec.ts create mode 100644 themes/triangles/server/src/lib/stubs/speakeasy.spec.ts create mode 100644 themes/triangles/server/src/lib/stubs/u2f.spec.ts create mode 100644 themes/triangles/server/src/lib/utils/HashGenerator.spec.ts create mode 100644 themes/triangles/server/src/lib/utils/HashGenerator.ts create mode 100644 themes/triangles/server/src/lib/utils/ObjectCloner.ts create mode 100644 themes/triangles/server/src/lib/utils/SafeRedirection.spec.ts create mode 100644 themes/triangles/server/src/lib/utils/SafeRedirection.ts create mode 100644 themes/triangles/server/src/lib/utils/URLDecomposer.spec.ts create mode 100644 themes/triangles/server/src/lib/utils/URLDecomposer.ts create mode 100644 themes/triangles/server/src/lib/web_server/Configurator.ts create mode 100644 themes/triangles/server/src/lib/web_server/RestApi.ts create mode 100644 themes/triangles/server/src/lib/web_server/middlewares/RequireValidatedFirstFactor.ts create mode 100644 themes/triangles/server/src/lib/web_server/middlewares/WithHeadersLogged.ts create mode 100644 themes/triangles/server/src/resources/email-template.ejs create mode 100644 themes/triangles/server/src/views/already-logged-in.pug create mode 100644 themes/triangles/server/src/views/errors/.directory create mode 100644 themes/triangles/server/src/views/errors/401.pug create mode 100644 themes/triangles/server/src/views/errors/403.pug create mode 100644 themes/triangles/server/src/views/errors/404.pug create mode 100644 themes/triangles/server/src/views/firstfactor.pug create mode 100644 themes/triangles/server/src/views/layout/layout.pug create mode 100644 themes/triangles/server/src/views/need-identity-validation.pug create mode 100644 themes/triangles/server/src/views/password-reset-form.pug create mode 100644 themes/triangles/server/src/views/password-reset-request.pug create mode 100644 themes/triangles/server/src/views/secondfactor.pug create mode 100644 themes/triangles/server/src/views/totp-register.pug create mode 100644 themes/triangles/server/src/views/u2f-register.pug create mode 100644 themes/triangles/server/test/requests.ts create mode 100644 themes/triangles/server/tsconfig.json create mode 100644 themes/triangles/server/tslint.json create mode 100644 themes/triangles/server/types/.directory create mode 100644 themes/triangles/server/types/AuthenticationSession.ts create mode 100644 themes/triangles/server/types/Dependencies.ts create mode 100644 themes/triangles/server/types/Identity.ts create mode 100644 themes/triangles/server/types/TOTPSecret.ts create mode 100644 themes/triangles/server/types/U2FRegistration.ts create mode 100644 themes/triangles/server/types/dovehash.d.ts create mode 100644 themes/triangles/server/types/speakeasy.d.ts diff --git a/Gruntfile.js b/Gruntfile.js index 1509cc90..fcf4a573 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -155,6 +155,54 @@ module.exports = function (grunt) { src: '**', dest: `${buildDir}/server/src/public_html/js/` }, + squares_resources: { + expand: true, + cwd: 'themes/squares/server/src/resources', + src: '**', + dest: `${buildDir}/server/src/resources/` + }, + squares_views: { + expand: true, + cwd: 'themes/squares/server/src/views', + src: '**', + dest: `${buildDir}/server/src/views/` + }, + squares_images: { + expand: true, + cwd: 'themes/squares/client/src/img', + src: '**', + dest: `${buildDir}/server/src/public_html/img/` + }, + squares_thirdparties: { + expand: true, + cwd: 'themes/squares/client/src/thirdparties', + src: '**', + dest: `${buildDir}/server/src/public_html/js/` + }, + triangles_resources: { + expand: true, + cwd: 'themes/triangles/server/src/resources', + src: '**', + dest: `${buildDir}/server/src/resources/` + }, + triangles_views: { + expand: true, + cwd: 'themes/triangles/server/src/views', + src: '**', + dest: `${buildDir}/server/src/views/` + }, + triangles_images: { + expand: true, + cwd: 'themes/triangles/client/src/img', + src: '**', + dest: `${buildDir}/server/src/public_html/img/` + }, + triangles_thirdparties: { + expand: true, + cwd: 'themes/triangles/client/src/thirdparties', + src: '**', + dest: `${buildDir}/server/src/public_html/js/` + }, schema: { src: schemaDir, dest: `${buildDir}/${schemaDir}` @@ -234,6 +282,14 @@ module.exports = function (grunt) { src: ['themes/black/client/src/css/*.css'], dest: `${buildDir}/server/src/public_html/css/authelia.css` }, + squares_css: { + src: ['themes/squares/client/src/css/*.css'], + dest: `${buildDir}/server/src/public_html/css/authelia.css` + }, + triangles_css: { + src: ['themes/triangles/client/src/css/*.css'], + dest: `${buildDir}/server/src/public_html/css/authelia.css` + }, }, cssmin: { target: { @@ -266,18 +322,24 @@ module.exports = function (grunt) { grunt.registerTask('test-int', ['run:test-cucumber', 'run:test-minimal-config', 'run:test-complete-config', 'run:test-inactivity']); grunt.registerTask('copy-resources-main', ['copy:main_resources', 'copy:main_views', 'copy:main_images', 'copy:main_thirdparties', 'concat:main_css']); - - grunt.registerTask('generate-config-schema', ['run:generate-config-schema', 'copy:schema']); 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('copy-resources-squares', ['copy:squares_resources', 'copy:squares_views', 'copy:squares_images', 'copy:squares_thirdparties', 'concat:squares_css']); + + grunt.registerTask('copy-resources-triangles', ['copy:triangles_resources', 'copy:triangles_views', 'copy:triangles_images', 'copy:triangles_thirdparties', 'concat:triangles_css']); + + grunt.registerTask('generate-config-schema', ['run:generate-config-schema', 'copy:schema']); 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-server-squares', ['compile-server', 'copy-resources-squares', 'generate-config-schema']); + grunt.registerTask('build-server-triangles', ['compile-server', 'copy-resources-triangles', '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/squares/client/src/css/.directory b/themes/squares/client/src/css/.directory new file mode 100644 index 00000000..6e4b3f63 --- /dev/null +++ b/themes/squares/client/src/css/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,17,20,56,41 +Version=3 +ViewMode=1 diff --git a/themes/squares/client/src/css/00-bootstrap.min.css b/themes/squares/client/src/css/00-bootstrap.min.css new file mode 100644 index 00000000..dfeacbb8 --- /dev/null +++ b/themes/squares/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/squares/client/src/css/01-main.css b/themes/squares/client/src/css/01-main.css new file mode 100644 index 00000000..be80c222 --- /dev/null +++ b/themes/squares/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/squares/client/src/css/02-login.css b/themes/squares/client/src/css/02-login.css new file mode 100644 index 00000000..a6984267 --- /dev/null +++ b/themes/squares/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/squares/client/src/css/03-errors.css b/themes/squares/client/src/css/03-errors.css new file mode 100644 index 00000000..e9f97f33 --- /dev/null +++ b/themes/squares/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/squares/client/src/css/03-password-reset-form.css b/themes/squares/client/src/css/03-password-reset-form.css new file mode 100644 index 00000000..34066bc2 --- /dev/null +++ b/themes/squares/client/src/css/03-password-reset-form.css @@ -0,0 +1,4 @@ + +.password-reset-form .header-img { + border-radius: 0%; +} diff --git a/themes/squares/client/src/css/03-password-reset-request.css b/themes/squares/client/src/css/03-password-reset-request.css new file mode 100644 index 00000000..1a2ad4df --- /dev/null +++ b/themes/squares/client/src/css/03-password-reset-request.css @@ -0,0 +1,4 @@ + +.password-reset-request .header-img { + border-radius: 0%; +} diff --git a/themes/squares/client/src/css/03-totp-register.css b/themes/squares/client/src/css/03-totp-register.css new file mode 100644 index 00000000..cb76720a --- /dev/null +++ b/themes/squares/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/squares/client/src/css/03-u2f-register.css b/themes/squares/client/src/css/03-u2f-register.css new file mode 100644 index 00000000..e54cddf8 --- /dev/null +++ b/themes/squares/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/squares/client/src/img/LargeTriangles.svg b/themes/squares/client/src/img/LargeTriangles.svg new file mode 100644 index 00000000..0988bcb3 --- /dev/null +++ b/themes/squares/client/src/img/LargeTriangles.svg @@ -0,0 +1 @@ +<svg xmlns='http://www.w3.org/2000/svg' width='300' height='250' viewBox='0 0 1080 900'><rect fill='#000000' width='1080' height='900'/><g fill-opacity='0.16'><polygon fill='#444' points='90 150 0 300 180 300'/><polygon points='90 150 180 0 0 0'/><polygon fill='#AAA' points='270 150 360 0 180 0'/><polygon fill='#DDD' points='450 150 360 300 540 300'/><polygon fill='#999' points='450 150 540 0 360 0'/><polygon points='630 150 540 300 720 300'/><polygon fill='#DDD' points='630 150 720 0 540 0'/><polygon fill='#444' points='810 150 720 300 900 300'/><polygon fill='#FFF' points='810 150 900 0 720 0'/><polygon fill='#DDD' points='990 150 900 300 1080 300'/><polygon fill='#444' points='990 150 1080 0 900 0'/><polygon fill='#DDD' points='90 450 0 600 180 600'/><polygon points='90 450 180 300 0 300'/><polygon fill='#666' points='270 450 180 600 360 600'/><polygon fill='#AAA' points='270 450 360 300 180 300'/><polygon fill='#DDD' points='450 450 360 600 540 600'/><polygon fill='#999' points='450 450 540 300 360 300'/><polygon fill='#999' points='630 450 540 600 720 600'/><polygon fill='#FFF' points='630 450 720 300 540 300'/><polygon points='810 450 720 600 900 600'/><polygon fill='#DDD' points='810 450 900 300 720 300'/><polygon fill='#AAA' points='990 450 900 600 1080 600'/><polygon fill='#444' points='990 450 1080 300 900 300'/><polygon fill='#222' points='90 750 0 900 180 900'/><polygon points='270 750 180 900 360 900'/><polygon fill='#DDD' points='270 750 360 600 180 600'/><polygon points='450 750 540 600 360 600'/><polygon points='630 750 540 900 720 900'/><polygon fill='#444' points='630 750 720 600 540 600'/><polygon fill='#AAA' points='810 750 720 900 900 900'/><polygon fill='#666' points='810 750 900 600 720 600'/><polygon fill='#999' points='990 750 900 900 1080 900'/><polygon fill='#999' points='180 0 90 150 270 150'/><polygon fill='#444' points='360 0 270 150 450 150'/><polygon fill='#FFF' points='540 0 450 150 630 150'/><polygon points='900 0 810 150 990 150'/><polygon fill='#222' points='0 300 -90 450 90 450'/><polygon fill='#FFF' points='0 300 90 150 -90 150'/><polygon fill='#FFF' points='180 300 90 450 270 450'/><polygon fill='#666' points='180 300 270 150 90 150'/><polygon fill='#222' points='360 300 270 450 450 450'/><polygon fill='#FFF' points='360 300 450 150 270 150'/><polygon fill='#444' points='540 300 450 450 630 450'/><polygon fill='#222' points='540 300 630 150 450 150'/><polygon fill='#AAA' points='720 300 630 450 810 450'/><polygon fill='#666' points='720 300 810 150 630 150'/><polygon fill='#FFF' points='900 300 810 450 990 450'/><polygon fill='#999' points='900 300 990 150 810 150'/><polygon points='0 600 -90 750 90 750'/><polygon fill='#666' points='0 600 90 450 -90 450'/><polygon fill='#AAA' points='180 600 90 750 270 750'/><polygon fill='#444' points='180 600 270 450 90 450'/><polygon fill='#444' points='360 600 270 750 450 750'/><polygon fill='#999' points='360 600 450 450 270 450'/><polygon fill='#666' points='540 600 630 450 450 450'/><polygon fill='#222' points='720 600 630 750 810 750'/><polygon fill='#FFF' points='900 600 810 750 990 750'/><polygon fill='#222' points='900 600 990 450 810 450'/><polygon fill='#DDD' points='0 900 90 750 -90 750'/><polygon fill='#444' points='180 900 270 750 90 750'/><polygon fill='#FFF' points='360 900 450 750 270 750'/><polygon fill='#AAA' points='540 900 630 750 450 750'/><polygon fill='#FFF' points='720 900 810 750 630 750'/><polygon fill='#222' points='900 900 990 750 810 750'/><polygon fill='#222' points='1080 300 990 450 1170 450'/><polygon fill='#FFF' points='1080 300 1170 150 990 150'/><polygon points='1080 600 990 750 1170 750'/><polygon fill='#666' points='1080 600 1170 450 990 450'/><polygon fill='#DDD' points='1080 900 1170 750 990 750'/></g></svg> \ No newline at end of file diff --git a/themes/squares/client/src/img/RandomizedPattern.svg b/themes/squares/client/src/img/RandomizedPattern.svg new file mode 100644 index 00000000..51afee6d --- /dev/null +++ b/themes/squares/client/src/img/RandomizedPattern.svg @@ -0,0 +1 @@ +<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='100' height='50' viewBox='0 0 100 50'><rect fill='#000000' width='50' height='25'/><defs><rect stroke='#000000' stroke-width='0.5' width='1' height='1' id='s'/><pattern id='a' width='2' height='2' patternUnits='userSpaceOnUse'><g stroke='#000000' stroke-width='0.5'><rect fill='#050505' width='1' height='1'/><rect fill='#000000' width='1' height='1' x='1' y='1'/><rect fill='#0a0a0a' width='1' height='1' y='1'/><rect fill='#0f0f0f' width='1' height='1' x='1'/></g></pattern><pattern id='b' width='5' height='11' patternUnits='userSpaceOnUse'><g fill='#141414'><use xlink:href='#s' x='2' y='0'/><use xlink:href='#s' x='4' y='1'/><use xlink:href='#s' x='1' y='2'/><use xlink:href='#s' x='2' y='4'/><use xlink:href='#s' x='4' y='6'/><use xlink:href='#s' x='0' y='8'/><use xlink:href='#s' x='3' y='9'/></g></pattern><pattern id='c' width='7' height='7' patternUnits='userSpaceOnUse'><g fill='#1a1a1a'><use xlink:href='#s' x='1' y='1'/><use xlink:href='#s' x='3' y='4'/><use xlink:href='#s' x='5' y='6'/><use xlink:href='#s' x='0' y='3'/></g></pattern><pattern id='d' width='11' height='5' patternUnits='userSpaceOnUse'><g fill='#000000'><use xlink:href='#s' x='1' y='1'/><use xlink:href='#s' x='6' y='3'/><use xlink:href='#s' x='8' y='2'/><use xlink:href='#s' x='3' y='0'/><use xlink:href='#s' x='0' y='3'/></g><g fill='#1f1f1f'><use xlink:href='#s' x='8' y='3'/><use xlink:href='#s' x='4' y='2'/><use xlink:href='#s' x='5' y='4'/><use xlink:href='#s' x='10' y='0'/></g></pattern><pattern id='e' width='47' height='23' patternUnits='userSpaceOnUse'><g fill='#000000'><use xlink:href='#s' x='2' y='5'/><use xlink:href='#s' x='23' y='13'/><use xlink:href='#s' x='4' y='18'/><use xlink:href='#s' x='35' y='9'/></g></pattern><pattern id='f' width='61' height='31' patternUnits='userSpaceOnUse'><g fill='#000000'><use xlink:href='#s' x='16' y='0'/><use xlink:href='#s' x='13' y='22'/><use xlink:href='#s' x='44' y='15'/><use xlink:href='#s' x='12' y='11'/></g></pattern></defs><rect fill='url(#a)' width='100' height='50'/><rect fill='url(#b)' width='100' height='50'/><rect fill='url(#c)' width='100' height='50'/><rect fill='url(#d)' width='100' height='50'/><rect fill='url(#e)' width='100' height='50'/><rect fill='url(#f)' width='100' height='50'/></svg> \ No newline at end of file diff --git a/themes/squares/client/src/img/background.jpg b/themes/squares/client/src/img/background.jpg new file mode 100644 index 0000000000000000000000000000000000000000..974ea273fa87adccec4b4433fc4d097c04ae4c31 GIT binary patch literal 587 zcmb7<L2d#u3`M__34}>za3BG;m3jxdn?<FO;0Ua`=skLeO1)6UA)wuGli(4e5-l+5 zk2kTNzrXV?AHeH&)q;qyk%#<^XK1Cm5*1R$8dDUe91aK8m)^V5xvHLURfpO+o^hDQ zl#(CM7qhsS#1uOd(lS$+kujrKxhno!`4hq76;GN1R3IHFZ;>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/squares/client/src/img/background.svg b/themes/squares/client/src/img/background.svg new file mode 100644 index 00000000..668312f9 --- /dev/null +++ b/themes/squares/client/src/img/background.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="56" height="100"> +<rect width="56" height="100" fill="#000000"></rect> +<path d="M28 66L0 50L0 16L28 0L56 16L56 50L28 66L28 100" fill="none" stroke="#FCFCFC" stroke-width="2"></path> +<path d="M28 0L28 34L0 50L0 84L28 100L56 84L56 50L28 34" fill="none" stroke="#FBFBFB" stroke-width="2"></path> +</svg> diff --git a/themes/squares/client/src/img/icon.png b/themes/squares/client/src/img/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..040d10c1ab5feaf6720fa8e8c0a0aa336402894e GIT binary patch literal 1461 zcmV;m1xosfP)<h;3K|Lk000e1NJLTq001BW001Be1^@s6b9#F800006VoOIv0000h z08pkdLy7<Z010qNS#tmYE+YT{E+YYWr9XB6000McNliru;s_oG8!(+tmiGVv02y>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&;<c#bG`q|<OHK2`(Z1F*_Z`5B=eE$u=<U^0j`K;>aqBgCCWIgr3<ka|x8+B} z=a2UPs{j_R*hn!>%#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*TPM9r<qXcd6m05r&RoHm4m;gEgAmh$xI#g^d-02A2<SOZKs0Fakk zCV;f}N;04Ylv2Ko8hJ~C0f4tf`c7QSTS2Kbrw{nRCq)1PUM6U!-pFG)X}mxQr@rxh z%NZxx&ItDaAiIbP3TFUA=k9FE-|@ywV|M{5Mwq>NfXHy+Yt0<CGX!NMV4F_bX_3Cu z6leytj)3)gF#nvPg|ic(!y#Mc1rUoTx(oSxaOq1;waYiG^KtBDU<#}^ps%F`%pZG! z@ZDuK7P-_uke7mR$W{}vgl0hG;;m*$)0<=C;|CNG#6!=2pfhQn0(c{?w0B`))3ady z%0Cl4FjCNznvH_Um5u=-jHPHOD-ABr*1N8|IHB3gHn0o87637TH>3Y#2NpCw4d56E zr8CNk8ARxwwFZ)MCdHT&Pdu5eC<zAkNFjQ&&OQagySVy88|F8z0_Zr1DnP>DZpn@^ zg!ri-09%OTsSF5HzMiccym0J>)_N^~%UQkOi}{T!LDU98Rq07el%z1z(&3<exFCS? zva(J?c=IzJPpo=))%qG=(T|)R0OJKP_a}$WG7#DzXpR@T$gvX`0V#(p+v+a}psJ?) z&!BCd02o@sm>7?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<Qro>-`$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/squares/client/src/img/mail.png b/themes/squares/client/src/img/mail.png new file mode 100644 index 0000000000000000000000000000000000000000..834bfce9107a94be10da4c011fb78e37a192888f GIT binary patch literal 3545 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 Jtx8RN)PI?rK$`#n literal 0 HcmV?d00001 diff --git a/themes/squares/client/src/img/matrix_circle_128x128.png b/themes/squares/client/src/img/matrix_circle_128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..856e01556b91e0ac25959bd41e9af51888d9ac56 GIT binary patch literal 35750 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 literal 0 HcmV?d00001 diff --git a/themes/squares/client/src/img/notifications/.directory b/themes/squares/client/src/img/notifications/.directory new file mode 100644 index 00000000..7c8b8054 --- /dev/null +++ b/themes/squares/client/src/img/notifications/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,17,20,57,35 +Version=3 +ViewMode=1 diff --git a/themes/squares/client/src/img/notifications/error.png b/themes/squares/client/src/img/notifications/error.png new file mode 100644 index 0000000000000000000000000000000000000000..bf64d28f7519c816cf348e8a1d39ef0a5d588cf1 GIT binary patch literal 863 zcmV-l1EBngP)<h;3K|Lk000e1NJLTq001BW001Be0{{R3M5Kzw00003b3#c}2nYz< z;ZNWI000SaNLh0L007_s007_tqF?^X0000PbVXQnQ*UN;cVTj60C#tHE@^ISb7Ns} zWiD@WXPfRk8UO$RHBd}cMgRZ*|4B*zU_kU$O7&7l;666mJT>%BO3pDW&@U?SP)W=! zDc?Ui-aR+*QAzPrN%B%k(lRg6F)Yt8EY2<}@KQ<fQc1=qBgQBs@lr|hQc2i4GsGn# z(K0Q+As)RR9Pv^~@lr|gQc1QK6}A@^x*Hk08yT_^60;K$tqu;Y4-c&n5Uvjot`HEf z6BDo(7qJ~3vKt%6ZEeSHZqqa`(|&%`r>E7hu-4Yr*R{3Rwzk*2yw|<G*VotBy}j9x zklEPS+LM#o*4EoRHrqWn+m@Ew*4EsZnBSwL;Av&xq@>}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<L2q=>+SOM_51t${QmxXRol}500C!7L_t(I%XO1yLxV67h6AluQ2_@iwQ8v|?ok!@ z-YYn2>#nW&|35*<zyR_6!g23IayfDUL{c=<a$MK3Oicl4WsM?=R5Y>)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<bW zoxxc`n84x{*F^tn4pPDb4w4X;flmnsxF}7W6Q2;S47l5!ft+w9Tiklyc0>%Oiwu0v z+v3DX>X<hk(K-YAl8rjqq`=N11-cTgK7f!ChttF#SK>cwFHCNP3#`xu`i0+p@$j}v zvupdO%QTv77cWeCo)rccc+eBSa%GrMK}}rR1Tof<y7n<*v_mpD0#Rs{jvgnbx?`g! p3@;%j2HGU^&BlLdxVKXn|6j&?*~IE2*wO$1002ovPDHLkV1fhnr_}%e literal 0 HcmV?d00001 diff --git a/themes/squares/client/src/img/notifications/info.png b/themes/squares/client/src/img/notifications/info.png new file mode 100644 index 0000000000000000000000000000000000000000..67928e88c95fca77407947ff12e43039dd0b3067 GIT binary patch literal 732 zcmV<20wev2P)<h;3K|Lk000e1NJLTq001BW001Be0{{R3M5Kzw00003b3#c}2nYz< z;ZNWI000SaNLh0L0089x0089ykL8;@0000PbVXQnQ*UN;cVTj60C#tHE@^ISb7Ns} zWiD@WXPfRk8UO$Q_E1bzMgRZ*B(~cizuzFW)hW2vD7Dfiw$dfI(<QdkCAZThxYH%L z(j~UjCAZThx6>uJ(<QglCAZTix6>!M(<r#qEV<Mzy45ba)iAr&GP~9`z1BLu*E_z~ zJipjKzt}{<*+;_KNW<Do!`oBF+*QZjS;*d7$=+Sb-d)PxW6R%T%;06r;Azd_Z_na% z(c^m4<bl=ZgVyGX*yoJd=#ks$mE7u>-Rha$>YCo`p5N@L;_j;B?zHCcxaje_>G8bj z^1kZw$L#db@AlE~_S5k8)$#Y&^7q~J_~G^W<o5dT`uy|z{q_9)`u+a={{H>`|Nj5~ z|Ns9nkp&R|000qmQchC<2M8D$ElGrzn8C=}<MQ+M)u=IB0003yNkl<ZILn1oS6jkR z3{6YvqJseLjT2FEBjO$?PR{@TGd>`_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|* O0000<MNUMnLSTYudwt~q literal 0 HcmV?d00001 diff --git a/themes/squares/client/src/img/notifications/success.png b/themes/squares/client/src/img/notifications/success.png new file mode 100644 index 0000000000000000000000000000000000000000..d3998392dec4edddb1b62c2c9b81f6bc8c3e4132 GIT binary patch literal 931 zcmV;U16=%xP)<h;3K|Lk000e1NJLTq001BW001Be0{{R3M5Kzw00003b3#c}2nYz< z;ZNWI000SaNLh0L007_s007_tqF?^X0000PbVXQnQ*UN;cVTj60C#tHE@^ISb7Ns} zWiD@WXPfRk8UO$RZct2AMgRZ*B(`lpzkoT$aVN2M52a}}zkoBidMmScD7SbXvUfAP zeJHbeF}ZpjtaKc#b27PnGrD^+x_mFVdmpWJ7^!m}t#uu(bTYbp8?1CPx_mOad@i?o zGrD{ks&f;jaWcDn5v6c4x_mOad=sW|6Q*$yq;L?Va1o?%4We%jqi+wSZwjDp5~Xny zrg0Ugau=y{8mn|As(vf5fH1jxF}Zv)u!b_ad^50yG_Z#>yM8sXh&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|E<o537_wVZW^6U5W?D+KT`Sb1g^zHfc?fLZX`1S7j^zZuh@cQ-f`u6tx z`S$$!`273){{8*_{r&#_|NsAug0YnV001m>QchC<2M7rb5f~XBAS*37Npg6EmzbTd zzP`c1#K_v+<K*x1^Yiug`1<|*{{H@<nC93300D1FL_t(I%XO3YTf#69h65^~SZ(X7 zMHIn>#*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<idKQDQhYgPA?FW$`4WYNdul z8vZ-^po{c4<1kKtF?E4OrAI9eo7f>|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|38<V#k&v!+G79!002ovPDHLk FV1l=5+6w>x literal 0 HcmV?d00001 diff --git a/themes/squares/client/src/img/notifications/warning.png b/themes/squares/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{<PM>%Ew#`y9xfr~3@Z1+ro2RJG>m8qL$S znrCV?&(>?6YtTC1sCB+c>jDrpYh7&Cy4<F9wL|M_r}ni@?dx6IH@dZN^l0De)4tuW zeP^Q1-AOw4rs&+Cs`GG~&co?Ck7npRo~iR>w(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~n<p-i9nXY!tU#&M|=du+)*luVzFLC=Q_hRb8g<&7FAGk?I z2^X;2%wU-Nh_k{>vt(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/squares/client/src/img/padlock.png b/themes/squares/client/src/img/padlock.png new file mode 100644 index 0000000000000000000000000000000000000000..31abbaeefcbc507ac59450a76b222d0f4e2ef298 GIT binary patch literal 3265 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 literal 0 HcmV?d00001 diff --git a/themes/squares/client/src/img/password_white.png b/themes/squares/client/src/img/password_white.png new file mode 100644 index 0000000000000000000000000000000000000000..0b93ef3fb7535313d9378066d0485f69e6e627df GIT binary patch literal 3858 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 literal 0 HcmV?d00001 diff --git a/themes/squares/client/src/img/pendrive.png b/themes/squares/client/src/img/pendrive.png new file mode 100644 index 0000000000000000000000000000000000000000..fa49178c326631167ec642aaacc117c38b31f6bf GIT binary patch literal 6721 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 Hcm4kWRQYbi literal 0 HcmV?d00001 diff --git a/themes/squares/client/src/img/sharingan.png b/themes/squares/client/src/img/sharingan.png new file mode 100644 index 0000000000000000000000000000000000000000..526787d3be6a78d936b6a21941291994d0eca38e GIT binary patch literal 9213 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 G0sjMgjHl56 literal 0 HcmV?d00001 diff --git a/themes/squares/client/src/img/stores/.directory b/themes/squares/client/src/img/stores/.directory new file mode 100644 index 00000000..7bdc8daf --- /dev/null +++ b/themes/squares/client/src/img/stores/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,17,20,57,25 +Version=3 +ViewMode=1 diff --git a/themes/squares/client/src/img/stores/applestore-badge.svg b/themes/squares/client/src/img/stores/applestore-badge.svg new file mode 100644 index 00000000..ac111e59 --- /dev/null +++ b/themes/squares/client/src/img/stores/applestore-badge.svg @@ -0,0 +1,129 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg version="1.1" id="US_UK_Download_on_the" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" + x="0px" y="0px" width="135px" height="40px" viewBox="0 0 135 40" enable-background="new 0 0 135 40" xml:space="preserve"> +<g> + <path fill="#A6A6A6" d="M130.197,40H4.729C2.122,40,0,37.872,0,35.267V4.726C0,2.12,2.122,0,4.729,0h125.468 + C132.803,0,135,2.12,135,4.726v30.541C135,37.872,132.803,40,130.197,40L130.197,40z"/> + <path d="M134.032,35.268c0,2.116-1.714,3.83-3.834,3.83H4.729c-2.119,0-3.839-1.714-3.839-3.83V4.725 + c0-2.115,1.72-3.835,3.839-3.835h125.468c2.121,0,3.834,1.72,3.834,3.835L134.032,35.268L134.032,35.268z"/> + <g> + <g> + <path fill="#FFFFFF" d="M30.128,19.784c-0.029-3.223,2.639-4.791,2.761-4.864c-1.511-2.203-3.853-2.504-4.676-2.528 + c-1.967-0.207-3.875,1.177-4.877,1.177c-1.022,0-2.565-1.157-4.228-1.123c-2.14,0.033-4.142,1.272-5.24,3.196 + c-2.266,3.923-0.576,9.688,1.595,12.859c1.086,1.553,2.355,3.287,4.016,3.226c1.625-0.067,2.232-1.036,4.193-1.036 + c1.943,0,2.513,1.036,4.207,0.997c1.744-0.028,2.842-1.56,3.89-3.127c1.255-1.78,1.759-3.533,1.779-3.623 + C33.507,24.924,30.161,23.647,30.128,19.784z"/> + <path fill="#FFFFFF" d="M26.928,10.306c0.874-1.093,1.472-2.58,1.306-4.089c-1.265,0.056-2.847,0.875-3.758,1.944 + c-0.806,0.942-1.526,2.486-1.34,3.938C24.557,12.205,26.016,11.382,26.928,10.306z"/> + </g> + </g> + <g> + <path fill="#FFFFFF" d="M53.645,31.504h-2.271l-1.244-3.909h-4.324l-1.185,3.909h-2.211l4.284-13.308h2.646L53.645,31.504z + M49.755,25.955L48.63,22.48c-0.119-0.355-0.342-1.191-0.671-2.507h-0.04c-0.131,0.566-0.342,1.402-0.632,2.507l-1.105,3.475 + H49.755z"/> + <path fill="#FFFFFF" d="M64.662,26.588c0,1.632-0.441,2.922-1.323,3.869c-0.79,0.843-1.771,1.264-2.942,1.264 + c-1.264,0-2.172-0.454-2.725-1.362h-0.04v5.055h-2.132V25.067c0-1.026-0.027-2.079-0.079-3.159h1.875l0.119,1.521h0.04 + c0.711-1.146,1.79-1.718,3.238-1.718c1.132,0,2.077,0.447,2.833,1.342C64.284,23.949,64.662,25.127,64.662,26.588z M62.49,26.666 + c0-0.934-0.21-1.704-0.632-2.31c-0.461-0.632-1.08-0.948-1.856-0.948c-0.526,0-1.004,0.176-1.431,0.523 + c-0.428,0.35-0.708,0.807-0.839,1.373c-0.066,0.264-0.099,0.48-0.099,0.65v1.6c0,0.698,0.214,1.287,0.642,1.768 + s0.984,0.721,1.668,0.721c0.803,0,1.428-0.31,1.875-0.928C62.266,28.496,62.49,27.68,62.49,26.666z"/> + <path fill="#FFFFFF" d="M75.699,26.588c0,1.632-0.441,2.922-1.324,3.869c-0.789,0.843-1.77,1.264-2.941,1.264 + c-1.264,0-2.172-0.454-2.724-1.362H68.67v5.055h-2.132V25.067c0-1.026-0.027-2.079-0.079-3.159h1.875l0.119,1.521h0.04 + c0.71-1.146,1.789-1.718,3.238-1.718c1.131,0,2.076,0.447,2.834,1.342C75.32,23.949,75.699,25.127,75.699,26.588z M73.527,26.666 + c0-0.934-0.211-1.704-0.633-2.31c-0.461-0.632-1.078-0.948-1.855-0.948c-0.527,0-1.004,0.176-1.432,0.523 + c-0.428,0.35-0.707,0.807-0.838,1.373c-0.065,0.264-0.099,0.48-0.099,0.65v1.6c0,0.698,0.214,1.287,0.64,1.768 + c0.428,0.48,0.984,0.721,1.67,0.721c0.803,0,1.428-0.31,1.875-0.928C73.303,28.496,73.527,27.68,73.527,26.666z"/> + <path fill="#FFFFFF" d="M88.039,27.772c0,1.132-0.393,2.053-1.182,2.764c-0.867,0.777-2.074,1.165-3.625,1.165 + c-1.432,0-2.58-0.276-3.449-0.829l0.494-1.777c0.936,0.566,1.963,0.85,3.082,0.85c0.803,0,1.428-0.182,1.877-0.544 + c0.447-0.362,0.67-0.848,0.67-1.454c0-0.54-0.184-0.995-0.553-1.364c-0.367-0.369-0.98-0.712-1.836-1.029 + c-2.33-0.869-3.494-2.142-3.494-3.816c0-1.094,0.408-1.991,1.225-2.689c0.814-0.699,1.9-1.048,3.258-1.048 + c1.211,0,2.217,0.211,3.02,0.632l-0.533,1.738c-0.75-0.408-1.598-0.612-2.547-0.612c-0.75,0-1.336,0.185-1.756,0.553 + c-0.355,0.329-0.533,0.73-0.533,1.205c0,0.526,0.203,0.961,0.611,1.303c0.355,0.316,1,0.658,1.936,1.027 + c1.145,0.461,1.986,1,2.527,1.618C87.77,26.081,88.039,26.852,88.039,27.772z"/> + <path fill="#FFFFFF" d="M95.088,23.508h-2.35v4.659c0,1.185,0.414,1.777,1.244,1.777c0.381,0,0.697-0.033,0.947-0.099l0.059,1.619 + c-0.42,0.157-0.973,0.236-1.658,0.236c-0.842,0-1.5-0.257-1.975-0.77c-0.473-0.514-0.711-1.376-0.711-2.587v-4.837h-1.4v-1.6h1.4 + v-1.757l2.094-0.632v2.389h2.35V23.508z"/> + <path fill="#FFFFFF" d="M105.691,26.627c0,1.475-0.422,2.686-1.264,3.633c-0.883,0.975-2.055,1.461-3.516,1.461 + c-1.408,0-2.529-0.467-3.365-1.401s-1.254-2.113-1.254-3.534c0-1.487,0.43-2.705,1.293-3.652c0.861-0.948,2.023-1.422,3.484-1.422 + c1.408,0,2.541,0.467,3.396,1.402C105.283,24.021,105.691,25.192,105.691,26.627z M103.479,26.696 + c0-0.885-0.189-1.644-0.572-2.277c-0.447-0.766-1.086-1.148-1.914-1.148c-0.857,0-1.508,0.383-1.955,1.148 + c-0.383,0.634-0.572,1.405-0.572,2.317c0,0.885,0.189,1.644,0.572,2.276c0.461,0.766,1.105,1.148,1.936,1.148 + c0.814,0,1.453-0.39,1.914-1.168C103.281,28.347,103.479,27.58,103.479,26.696z"/> + <path fill="#FFFFFF" d="M112.621,23.783c-0.211-0.039-0.436-0.059-0.672-0.059c-0.75,0-1.33,0.283-1.738,0.85 + c-0.355,0.5-0.533,1.132-0.533,1.895v5.035h-2.131l0.02-6.574c0-1.106-0.027-2.113-0.08-3.021h1.857l0.078,1.836h0.059 + c0.225-0.631,0.58-1.139,1.066-1.52c0.475-0.343,0.988-0.514,1.541-0.514c0.197,0,0.375,0.014,0.533,0.039V23.783z"/> + <path fill="#FFFFFF" d="M122.156,26.252c0,0.382-0.025,0.704-0.078,0.967h-6.396c0.025,0.948,0.334,1.673,0.928,2.173 + c0.539,0.447,1.236,0.671,2.092,0.671c0.947,0,1.811-0.151,2.588-0.454l0.334,1.48c-0.908,0.396-1.98,0.593-3.217,0.593 + c-1.488,0-2.656-0.438-3.506-1.313c-0.848-0.875-1.273-2.05-1.273-3.524c0-1.447,0.395-2.652,1.186-3.613 + c0.828-1.026,1.947-1.539,3.355-1.539c1.383,0,2.43,0.513,3.141,1.539C121.873,24.047,122.156,25.055,122.156,26.252z + M120.123,25.699c0.014-0.632-0.125-1.178-0.414-1.639c-0.369-0.593-0.936-0.889-1.699-0.889c-0.697,0-1.264,0.289-1.697,0.869 + c-0.355,0.461-0.566,1.014-0.631,1.658H120.123z"/> + </g> + <g> + <g> + <path fill="#FFFFFF" d="M49.05,10.009c0,1.177-0.353,2.063-1.058,2.658c-0.653,0.549-1.581,0.824-2.783,0.824 + c-0.596,0-1.106-0.026-1.533-0.078V6.982c0.557-0.09,1.157-0.136,1.805-0.136c1.145,0,2.008,0.249,2.59,0.747 + C48.723,8.156,49.05,8.961,49.05,10.009z M47.945,10.038c0-0.763-0.202-1.348-0.606-1.756c-0.404-0.407-0.994-0.611-1.771-0.611 + c-0.33,0-0.611,0.022-0.844,0.068v4.889c0.129,0.02,0.365,0.029,0.708,0.029c0.802,0,1.421-0.223,1.857-0.669 + S47.945,10.892,47.945,10.038z"/> + <path fill="#FFFFFF" d="M54.909,11.037c0,0.725-0.207,1.319-0.621,1.785c-0.434,0.479-1.009,0.718-1.727,0.718 + c-0.692,0-1.243-0.229-1.654-0.689c-0.41-0.459-0.615-1.038-0.615-1.736c0-0.73,0.211-1.329,0.635-1.794s0.994-0.698,1.712-0.698 + c0.692,0,1.248,0.229,1.669,0.688C54.708,9.757,54.909,10.333,54.909,11.037z M53.822,11.071c0-0.435-0.094-0.808-0.281-1.119 + c-0.22-0.376-0.533-0.564-0.94-0.564c-0.421,0-0.741,0.188-0.961,0.564c-0.188,0.311-0.281,0.69-0.281,1.138 + c0,0.435,0.094,0.808,0.281,1.119c0.227,0.376,0.543,0.564,0.951,0.564c0.4,0,0.714-0.191,0.94-0.574 + C53.725,11.882,53.822,11.506,53.822,11.071z"/> + <path fill="#FFFFFF" d="M62.765,8.719l-1.475,4.714h-0.96l-0.611-2.047c-0.155-0.511-0.281-1.019-0.379-1.523h-0.019 + c-0.091,0.518-0.217,1.025-0.379,1.523l-0.649,2.047h-0.971l-1.387-4.714h1.077l0.533,2.241c0.129,0.53,0.235,1.035,0.32,1.513 + h0.019c0.078-0.394,0.207-0.896,0.389-1.503l0.669-2.25h0.854l0.641,2.202c0.155,0.537,0.281,1.054,0.378,1.552h0.029 + c0.071-0.485,0.178-1.002,0.32-1.552l0.572-2.202H62.765z"/> + <path fill="#FFFFFF" d="M68.198,13.433H67.15v-2.7c0-0.832-0.316-1.248-0.95-1.248c-0.311,0-0.562,0.114-0.757,0.343 + c-0.193,0.229-0.291,0.499-0.291,0.808v2.796h-1.048v-3.366c0-0.414-0.013-0.863-0.038-1.349h0.921l0.049,0.737h0.029 + c0.122-0.229,0.304-0.418,0.543-0.569c0.284-0.176,0.602-0.265,0.95-0.265c0.44,0,0.806,0.142,1.097,0.427 + c0.362,0.349,0.543,0.87,0.543,1.562V13.433z"/> + <path fill="#FFFFFF" d="M71.088,13.433h-1.047V6.556h1.047V13.433z"/> + <path fill="#FFFFFF" d="M77.258,11.037c0,0.725-0.207,1.319-0.621,1.785c-0.434,0.479-1.01,0.718-1.727,0.718 + c-0.693,0-1.244-0.229-1.654-0.689c-0.41-0.459-0.615-1.038-0.615-1.736c0-0.73,0.211-1.329,0.635-1.794s0.994-0.698,1.711-0.698 + c0.693,0,1.248,0.229,1.67,0.688C77.057,9.757,77.258,10.333,77.258,11.037z M76.17,11.071c0-0.435-0.094-0.808-0.281-1.119 + c-0.219-0.376-0.533-0.564-0.939-0.564c-0.422,0-0.742,0.188-0.961,0.564c-0.188,0.311-0.281,0.69-0.281,1.138 + c0,0.435,0.094,0.808,0.281,1.119c0.227,0.376,0.543,0.564,0.951,0.564c0.4,0,0.713-0.191,0.939-0.574 + C76.074,11.882,76.17,11.506,76.17,11.071z"/> + <path fill="#FFFFFF" d="M82.33,13.433h-0.941l-0.078-0.543h-0.029c-0.322,0.433-0.781,0.65-1.377,0.65 + c-0.445,0-0.805-0.143-1.076-0.427c-0.246-0.258-0.369-0.579-0.369-0.96c0-0.576,0.24-1.015,0.723-1.319 + c0.482-0.304,1.16-0.453,2.033-0.446V10.3c0-0.621-0.326-0.931-0.979-0.931c-0.465,0-0.875,0.117-1.229,0.349l-0.213-0.688 + c0.438-0.271,0.979-0.407,1.617-0.407c1.232,0,1.85,0.65,1.85,1.95v1.736C82.262,12.78,82.285,13.155,82.33,13.433z + M81.242,11.813v-0.727c-1.156-0.02-1.734,0.297-1.734,0.95c0,0.246,0.066,0.43,0.201,0.553c0.135,0.123,0.307,0.184,0.512,0.184 + c0.23,0,0.445-0.073,0.641-0.218c0.197-0.146,0.318-0.331,0.363-0.558C81.236,11.946,81.242,11.884,81.242,11.813z"/> + <path fill="#FFFFFF" d="M88.285,13.433h-0.93l-0.049-0.757h-0.029c-0.297,0.576-0.803,0.864-1.514,0.864 + c-0.568,0-1.041-0.223-1.416-0.669s-0.562-1.025-0.562-1.736c0-0.763,0.203-1.381,0.611-1.853c0.395-0.44,0.879-0.66,1.455-0.66 + c0.633,0,1.076,0.213,1.328,0.64h0.02V6.556h1.049v5.607C88.248,12.622,88.26,13.045,88.285,13.433z M87.199,11.445v-0.786 + c0-0.136-0.01-0.246-0.029-0.33c-0.059-0.252-0.186-0.464-0.379-0.635c-0.195-0.171-0.43-0.257-0.701-0.257 + c-0.391,0-0.697,0.155-0.922,0.466c-0.223,0.311-0.336,0.708-0.336,1.193c0,0.466,0.107,0.844,0.322,1.135 + c0.227,0.31,0.533,0.465,0.916,0.465c0.344,0,0.619-0.129,0.828-0.388C87.1,12.069,87.199,11.781,87.199,11.445z"/> + <path fill="#FFFFFF" d="M97.248,11.037c0,0.725-0.207,1.319-0.621,1.785c-0.434,0.479-1.008,0.718-1.727,0.718 + c-0.691,0-1.242-0.229-1.654-0.689c-0.41-0.459-0.615-1.038-0.615-1.736c0-0.73,0.211-1.329,0.635-1.794s0.994-0.698,1.713-0.698 + c0.691,0,1.248,0.229,1.668,0.688C97.047,9.757,97.248,10.333,97.248,11.037z M96.162,11.071c0-0.435-0.094-0.808-0.281-1.119 + c-0.221-0.376-0.533-0.564-0.941-0.564c-0.42,0-0.74,0.188-0.961,0.564c-0.188,0.311-0.281,0.69-0.281,1.138 + c0,0.435,0.094,0.808,0.281,1.119c0.227,0.376,0.543,0.564,0.951,0.564c0.4,0,0.715-0.191,0.941-0.574 + C96.064,11.882,96.162,11.506,96.162,11.071z"/> + <path fill="#FFFFFF" d="M102.883,13.433h-1.047v-2.7c0-0.832-0.316-1.248-0.951-1.248c-0.311,0-0.562,0.114-0.756,0.343 + s-0.291,0.499-0.291,0.808v2.796h-1.049v-3.366c0-0.414-0.012-0.863-0.037-1.349h0.92l0.049,0.737h0.029 + c0.123-0.229,0.305-0.418,0.543-0.569c0.285-0.176,0.602-0.265,0.951-0.265c0.439,0,0.805,0.142,1.096,0.427 + c0.363,0.349,0.543,0.87,0.543,1.562V13.433z"/> + <path fill="#FFFFFF" d="M109.936,9.504h-1.154v2.29c0,0.582,0.205,0.873,0.611,0.873c0.188,0,0.344-0.016,0.467-0.049 + l0.027,0.795c-0.207,0.078-0.479,0.117-0.814,0.117c-0.414,0-0.736-0.126-0.969-0.378c-0.234-0.252-0.35-0.676-0.35-1.271V9.504 + h-0.689V8.719h0.689V7.855l1.027-0.31v1.173h1.154V9.504z"/> + <path fill="#FFFFFF" d="M115.484,13.433h-1.049v-2.68c0-0.845-0.316-1.268-0.949-1.268c-0.486,0-0.818,0.245-1,0.735 + c-0.031,0.103-0.049,0.229-0.049,0.377v2.835h-1.047V6.556h1.047v2.841h0.02c0.33-0.517,0.803-0.775,1.416-0.775 + c0.434,0,0.793,0.142,1.078,0.427c0.355,0.355,0.533,0.883,0.533,1.581V13.433z"/> + <path fill="#FFFFFF" d="M121.207,10.853c0,0.188-0.014,0.346-0.039,0.475h-3.143c0.014,0.466,0.164,0.821,0.455,1.067 + c0.266,0.22,0.609,0.33,1.029,0.33c0.465,0,0.889-0.074,1.271-0.223l0.164,0.728c-0.447,0.194-0.973,0.291-1.582,0.291 + c-0.73,0-1.305-0.215-1.721-0.645c-0.418-0.43-0.625-1.007-0.625-1.731c0-0.711,0.193-1.303,0.582-1.775 + c0.406-0.504,0.955-0.756,1.648-0.756c0.678,0,1.193,0.252,1.541,0.756C121.068,9.77,121.207,10.265,121.207,10.853z + M120.207,10.582c0.008-0.311-0.061-0.579-0.203-0.805c-0.182-0.291-0.459-0.437-0.834-0.437c-0.342,0-0.621,0.142-0.834,0.427 + c-0.174,0.227-0.277,0.498-0.311,0.815H120.207z"/> + </g> + </g> +</g> +</svg> diff --git a/themes/squares/client/src/img/stores/googleplay-badge.svg b/themes/squares/client/src/img/stores/googleplay-badge.svg new file mode 100644 index 00000000..9e33e3aa --- /dev/null +++ b/themes/squares/client/src/img/stores/googleplay-badge.svg @@ -0,0 +1,429 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + id="svg2" + version="1.1" + inkscape:version="0.91 r13725" + xml:space="preserve" + width="135.71649" + height="40.018951" + viewBox="0 0 135.71649 40.018951" + sodipodi:docname="google-play-badge.svg"><metadata + id="metadata8"><rdf:RDF><cc:Work + rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs + id="defs6"><linearGradient + x1="31.7997" + y1="183.2903" + x2="15.0173" + y2="166.5079" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.8,0,0,-0.8,0,161.6)" + spreadMethod="pad" + id="linearGradient50"><stop + style="stop-opacity:1;stop-color:#00a0ff" + offset="0" + id="stop52" /><stop + style="stop-opacity:1;stop-color:#00a1ff" + offset="0.0066" + id="stop54" /><stop + style="stop-opacity:1;stop-color:#00beff" + offset="0.2601" + id="stop56" /><stop + style="stop-opacity:1;stop-color:#00d2ff" + offset="0.5122" + id="stop58" /><stop + style="stop-opacity:1;stop-color:#00dfff" + offset="0.7604" + id="stop60" /><stop + style="stop-opacity:1;stop-color:#00e3ff" + offset="1" + id="stop62" /></linearGradient><linearGradient + x1="43.8344" + y1="171.9986" + x2="19.637501" + y2="171.9986" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.8,0,0,-0.8,0,161.6)" + spreadMethod="pad" + id="linearGradient68"><stop + style="stop-opacity:1;stop-color:#ffe000" + offset="0" + id="stop70" /><stop + style="stop-opacity:1;stop-color:#ffbd00" + offset="0.4087" + id="stop72" /><stop + style="stop-opacity:1;stop-color:#ffa500" + offset="0.7754" + id="stop74" /><stop + style="stop-opacity:1;stop-color:#ff9c00" + offset="1" + id="stop76" /></linearGradient><linearGradient + x1="34.827" + y1="169.7039" + x2="12.0687" + y2="146.9456" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.8,0,0,-0.8,0,161.6)" + spreadMethod="pad" + id="linearGradient82"><stop + style="stop-opacity:1;stop-color:#ff3a44" + offset="0" + id="stop84" /><stop + style="stop-opacity:1;stop-color:#c31162" + offset="1" + id="stop86" /></linearGradient><linearGradient + x1="17.2973" + y1="191.82381" + x2="27.4599" + y2="181.6613" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.8,0,0,-0.8,0,161.6)" + spreadMethod="pad" + id="linearGradient92"><stop + style="stop-opacity:1;stop-color:#32a071" + offset="0" + id="stop94" /><stop + style="stop-opacity:1;stop-color:#2da771" + offset="0.0685" + id="stop96" /><stop + style="stop-opacity:1;stop-color:#15cf74" + offset="0.4762" + id="stop98" /><stop + style="stop-opacity:1;stop-color:#06e775" + offset="0.8009" + id="stop100" /><stop + style="stop-opacity:1;stop-color:#00f076" + offset="1" + id="stop102" /></linearGradient><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath110"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path112" + inkscape:connector-curvature="0" /></clipPath><mask + maskUnits="userSpaceOnUse" + x="0" + y="0" + width="1" + height="1" + id="mask114"><g + id="g116"><g + clip-path="url(#clipPath110)" + id="g118"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + style="fill:#000000;fill-opacity:0.2;fill-rule:nonzero;stroke:none" + id="path120" + inkscape:connector-curvature="0" /></g></g></mask><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath126"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path128" + inkscape:connector-curvature="0" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath130"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path132" + inkscape:connector-curvature="0" /></clipPath><pattern + patternTransform="matrix(1,0,0,-1,0,48)" + patternUnits="userSpaceOnUse" + x="0" + y="0" + width="124" + height="48" + id="pattern134"><g + id="g136" /><g + id="g138"><g + clip-path="url(#clipPath130)" + id="g140"><g + id="g142"><path + d="M 29.625,20.695 18.012,14.098 C 17.363,13.727 16.781,13.754 16.406,14.09 l -0.058,-0.063 0.058,-0.058 c 0.375,-0.336 0.957,-0.36 1.606,0.011 l 11.687,6.641 -0.074,0.074 z" + style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path144" /></g></g></g></pattern><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath158"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path160" + inkscape:connector-curvature="0" /></clipPath><mask + maskUnits="userSpaceOnUse" + x="0" + y="0" + width="1" + height="1" + id="mask162"><g + id="g164"><g + clip-path="url(#clipPath158)" + id="g166"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + style="fill:#000000;fill-opacity:0.12000002;fill-rule:nonzero;stroke:none" + id="path168" + inkscape:connector-curvature="0" /></g></g></mask><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath174"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path176" + inkscape:connector-curvature="0" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath178"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path180" + inkscape:connector-curvature="0" /></clipPath><pattern + patternTransform="matrix(1,0,0,-1,0,48)" + patternUnits="userSpaceOnUse" + x="0" + y="0" + width="124" + height="48" + id="pattern182"><g + id="g184" /><g + id="g186"><g + clip-path="url(#clipPath178)" + id="g188"><g + id="g190"><path + d="m 16.348,14.145 c -0.235,0.246 -0.371,0.628 -0.371,1.125 l 0,-0.118 c 0,-0.496 0.136,-0.879 0.371,-1.125 l 0.058,0.063 -0.058,0.055 z" + style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path192" /></g></g></g></pattern><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath206"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path208" + inkscape:connector-curvature="0" /></clipPath><mask + maskUnits="userSpaceOnUse" + x="0" + y="0" + width="1" + height="1" + id="mask210"><g + id="g212"><g + clip-path="url(#clipPath206)" + id="g214"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + style="fill:#000000;fill-opacity:0.12000002;fill-rule:nonzero;stroke:none" + id="path216" + inkscape:connector-curvature="0" /></g></g></mask><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath222"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path224" + inkscape:connector-curvature="0" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath226"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path228" + inkscape:connector-curvature="0" /></clipPath><pattern + patternTransform="matrix(1,0,0,-1,0,48)" + patternUnits="userSpaceOnUse" + x="0" + y="0" + width="124" + height="48" + id="pattern230"><g + id="g232" /><g + id="g234"><g + clip-path="url(#clipPath226)" + id="g236"><g + id="g238"><path + d="m 33.613,22.961 -3.988,-2.266 0.074,-0.074 3.914,2.223 c 0.559,0.316 0.836,0.734 0.836,1.156 -0.047,-0.379 -0.332,-0.75 -0.836,-1.039 z" + style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path240" /></g></g></g></pattern><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath254"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path256" + inkscape:connector-curvature="0" /></clipPath><mask + maskUnits="userSpaceOnUse" + x="0" + y="0" + width="1" + height="1" + id="mask258"><g + id="g260"><g + clip-path="url(#clipPath254)" + id="g262"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + style="fill:#000000;fill-opacity:0.25;fill-rule:nonzero;stroke:none" + id="path264" + inkscape:connector-curvature="0" /></g></g></mask><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath270"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path272" + inkscape:connector-curvature="0" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath274"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path276" + inkscape:connector-curvature="0" /></clipPath><pattern + patternTransform="matrix(1,0,0,-1,0,48)" + patternUnits="userSpaceOnUse" + x="0" + y="0" + width="124" + height="48" + id="pattern278"><g + id="g280" /><g + id="g282"><g + clip-path="url(#clipPath274)" + id="g284"><g + id="g286"><path + d="m 18.012,33.902 15.601,-8.863 c 0.508,-0.289 0.789,-0.66 0.836,-1.039 0,0.418 -0.277,0.836 -0.836,1.156 L 18.012,34.02 c -1.117,0.632 -2.035,0.105 -2.035,-1.176 l 0,-0.114 c 0,1.278 0.918,1.805 2.035,1.172 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path288" /></g></g></g></pattern></defs><sodipodi:namedview + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1" + objecttolerance="10" + gridtolerance="10" + guidetolerance="10" + inkscape:pageopacity="0" + inkscape:pageshadow="2" + inkscape:window-width="1366" + inkscape:window-height="705" + id="namedview4" + showgrid="false" + inkscape:zoom="7.6276974" + inkscape:cx="93.965168" + inkscape:cy="29.61582" + inkscape:window-x="-8" + inkscape:window-y="-8" + inkscape:window-maximized="1" + inkscape:current-layer="g10" /><g + id="g10" + inkscape:groupmode="layer" + inkscape:label="google-play-badge" + transform="matrix(1.25,0,0,-1.25,-9.4247625,49.85025)"><g + id="g12" + transform="matrix(1.0023923,0,0,0.99072975,-0.29664807,0)"><path + d="M 112,8 12,8 C 9.801,8 8,9.801 8,12 l 0,24 c 0,2.199 1.801,4 4,4 l 100,0 c 2.199,0 4,-1.801 4,-4 l 0,-24 c 0,-2.199 -1.801,-4 -4,-4 z" + style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path14" + inkscape:connector-curvature="0" /><path + d="m 112,39.359 c 1.852,0 3.359,-1.507 3.359,-3.359 l 0,-24 c 0,-1.852 -1.507,-3.359 -3.359,-3.359 l -100,0 c -1.852,0 -3.359,1.507 -3.359,3.359 l 0,24 c 0,1.852 1.507,3.359 3.359,3.359 l 100,0 M 112,40 12,40 C 9.801,40 8,38.199 8,36 L 8,12 C 8,9.801 9.801,8 12,8 l 100,0 c 2.199,0 4,1.801 4,4 l 0,24 c 0,2.199 -1.801,4 -4,4 z" + style="fill:#a6a6a6;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path16" + inkscape:connector-curvature="0" /><g + id="g18" + transform="matrix(1,0,0,-1,0,48)"><path + d="m 45.934,16.195 c 0,0.668 -0.2,1.203 -0.594,1.602 -0.453,0.473 -1.043,0.711 -1.766,0.711 -0.691,0 -1.281,-0.242 -1.765,-0.719 -0.485,-0.484 -0.727,-1.078 -0.727,-1.789 0,-0.711 0.242,-1.305 0.727,-1.785 0.484,-0.481 1.074,-0.723 1.765,-0.723 0.344,0 0.672,0.071 0.985,0.203 0.312,0.133 0.566,0.313 0.75,0.535 l -0.418,0.422 c -0.321,-0.379 -0.758,-0.566 -1.317,-0.566 -0.504,0 -0.941,0.176 -1.312,0.531 -0.367,0.356 -0.551,0.817 -0.551,1.383 0,0.566 0.184,1.031 0.551,1.387 0.371,0.351 0.808,0.531 1.312,0.531 0.535,0 0.985,-0.18 1.34,-0.535 0.234,-0.235 0.367,-0.559 0.402,-0.973 l -1.742,0 0,-0.578 2.324,0 c 0.028,0.125 0.036,0.246 0.036,0.363 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" + id="path20" + inkscape:connector-curvature="0" /></g><g + id="g22" + transform="matrix(1,0,0,-1,0,48)"><path + d="m 49.621,14.191 -2.183,0 0,1.52 1.968,0 0,0.578 -1.968,0 0,1.52 2.183,0 0,0.589 -2.801,0 0,-4.796 2.801,0 0,0.589 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" + id="path24" + inkscape:connector-curvature="0" /></g><g + id="g26" + transform="matrix(1,0,0,-1,0,48)"><path + d="m 52.223,18.398 -0.618,0 0,-4.207 -1.339,0 0,-0.589 3.297,0 0,0.589 -1.34,0 0,4.207 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" + id="path28" + inkscape:connector-curvature="0" /></g><g + id="g30" + transform="matrix(1,0,0,-1,0,48)"><path + d="m 55.949,18.398 0,-4.796 0.617,0 0,4.796 -0.617,0 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" + id="path32" + inkscape:connector-curvature="0" /></g><g + id="g34" + transform="matrix(1,0,0,-1,0,48)"><path + d="m 59.301,18.398 -0.613,0 0,-4.207 -1.344,0 0,-0.589 3.301,0 0,0.589 -1.344,0 0,4.207 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" + id="path36" + inkscape:connector-curvature="0" /></g><g + id="g38" + transform="matrix(1,0,0,-1,0,48)"><path + d="m 66.887,17.781 c -0.473,0.485 -1.059,0.727 -1.758,0.727 -0.703,0 -1.289,-0.242 -1.762,-0.727 C 62.895,17.297 62.66,16.703 62.66,16 c 0,-0.703 0.235,-1.297 0.707,-1.781 0.473,-0.485 1.059,-0.727 1.762,-0.727 0.695,0 1.281,0.242 1.754,0.731 0.476,0.488 0.711,1.078 0.711,1.777 0,0.703 -0.235,1.297 -0.707,1.781 z m -3.063,-0.402 c 0.356,0.359 0.789,0.539 1.305,0.539 0.512,0 0.949,-0.18 1.301,-0.539 0.355,-0.359 0.535,-0.82 0.535,-1.379 0,-0.559 -0.18,-1.02 -0.535,-1.379 -0.352,-0.359 -0.789,-0.539 -1.301,-0.539 -0.516,0 -0.949,0.18 -1.305,0.539 -0.355,0.359 -0.535,0.82 -0.535,1.379 0,0.559 0.18,1.02 0.535,1.379 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" + id="path40" + inkscape:connector-curvature="0" /></g><g + id="g42" + transform="matrix(1,0,0,-1,0,48)"><path + d="m 68.461,18.398 0,-4.796 0.75,0 2.332,3.73 0.027,0 -0.027,-0.922 0,-2.808 0.617,0 0,4.796 -0.644,0 -2.442,-3.914 -0.027,0 0.027,0.926 0,2.988 -0.613,0 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" + id="path44" + inkscape:connector-curvature="0" /></g><path + d="m 62.508,22.598 c -1.879,0 -3.414,-1.43 -3.414,-3.403 0,-1.957 1.535,-3.402 3.414,-3.402 1.883,0 3.418,1.445 3.418,3.402 0,1.973 -1.535,3.403 -3.418,3.403 z m 0,-5.465 c -1.031,0 -1.918,0.851 -1.918,2.062 0,1.227 0.887,2.063 1.918,2.063 1.031,0 1.922,-0.836 1.922,-2.063 0,-1.211 -0.891,-2.062 -1.922,-2.062 z m -7.449,5.465 c -1.883,0 -3.414,-1.43 -3.414,-3.403 0,-1.957 1.531,-3.402 3.414,-3.402 1.882,0 3.414,1.445 3.414,3.402 0,1.973 -1.532,3.403 -3.414,3.403 z m 0,-5.465 c -1.032,0 -1.922,0.851 -1.922,2.062 0,1.227 0.89,2.063 1.922,2.063 1.031,0 1.918,-0.836 1.918,-2.063 0,-1.211 -0.887,-2.062 -1.918,-2.062 z m -8.864,4.422 0,-1.446 3.453,0 c -0.101,-0.808 -0.371,-1.402 -0.785,-1.816 -0.504,-0.5 -1.289,-1.055 -2.668,-1.055 -2.125,0 -3.789,1.715 -3.789,3.84 0,2.125 1.664,3.84 3.789,3.84 1.149,0 1.985,-0.449 2.602,-1.031 l 1.019,1.019 c -0.863,0.824 -2.011,1.457 -3.621,1.457 -2.914,0 -5.363,-2.371 -5.363,-5.285 0,-2.914 2.449,-5.285 5.363,-5.285 1.575,0 2.758,0.516 3.688,1.484 0.953,0.953 1.25,2.293 1.25,3.375 0,0.336 -0.028,0.645 -0.078,0.903 l -4.86,0 z m 36.246,-1.121 c -0.281,0.761 -1.148,2.164 -2.914,2.164 -1.75,0 -3.207,-1.379 -3.207,-3.403 0,-1.906 1.442,-3.402 3.375,-3.402 1.563,0 2.465,0.953 2.836,1.508 l -1.16,0.773 c -0.387,-0.566 -0.914,-0.941 -1.676,-0.941 -0.757,0 -1.3,0.347 -1.648,1.031 l 4.551,1.883 -0.157,0.387 z m -4.64,-1.133 c -0.039,1.312 1.019,1.984 1.777,1.984 0.594,0 1.098,-0.297 1.266,-0.722 L 77.801,19.301 Z M 74.102,16 l 1.496,0 0,10 -1.496,0 0,-10 z m -2.45,5.84 -0.05,0 c -0.336,0.398 -0.977,0.758 -1.789,0.758 -1.704,0 -3.262,-1.496 -3.262,-3.414 0,-1.907 1.558,-3.391 3.262,-3.391 0.812,0 1.453,0.363 1.789,0.773 l 0.05,0 0,-0.488 c 0,-1.301 -0.695,-2 -1.816,-2 -0.914,0 -1.481,0.66 -1.715,1.215 L 66.82,14.75 c 0.375,-0.902 1.368,-2.012 3.016,-2.012 1.754,0 3.234,1.032 3.234,3.543 l 0,6.11 -1.418,0 0,-0.551 z m -1.711,-4.707 c -1.031,0 -1.894,0.863 -1.894,2.051 0,1.199 0.863,2.074 1.894,2.074 1.016,0 1.817,-0.875 1.817,-2.074 0,-1.188 -0.801,-2.051 -1.817,-2.051 z M 89.445,26 l -3.578,0 0,-10 1.492,0 0,3.789 2.086,0 c 1.657,0 3.282,1.199 3.282,3.106 0,1.906 -1.629,3.105 -3.282,3.105 z m 0.039,-4.82 -2.125,0 0,3.429 2.125,0 c 1.114,0 1.75,-0.925 1.75,-1.714 0,-0.774 -0.636,-1.715 -1.75,-1.715 z m 9.223,1.437 c -1.078,0 -2.199,-0.476 -2.66,-1.531 l 1.324,-0.555 c 0.285,0.555 0.809,0.735 1.363,0.735 0.774,0 1.559,-0.465 1.571,-1.286 l 0,-0.105 c -0.27,0.156 -0.848,0.387 -1.559,0.387 -1.426,0 -2.879,-0.785 -2.879,-2.25 0,-1.34 1.168,-2.203 2.481,-2.203 1.004,0 1.558,0.453 1.906,0.98 l 0.051,0 0,-0.773 1.441,0 0,3.836 c 0,1.773 -1.324,2.765 -3.039,2.765 z m -0.18,-5.48 c -0.488,0 -1.168,0.242 -1.168,0.847 0,0.774 0.848,1.071 1.582,1.071 0.657,0 0.965,-0.145 1.364,-0.336 -0.117,-0.926 -0.914,-1.582 -1.778,-1.582 z m 8.469,5.261 -1.715,-4.335 -0.051,0 -1.773,4.335 -1.609,0 2.664,-6.058 -1.52,-3.371 1.559,0 4.105,9.429 -1.66,0 z M 93.547,16 l 1.496,0 0,10 -1.496,0 0,-10 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path46" + inkscape:connector-curvature="0" /><g + id="g48"><path + d="M 16.348,33.969 C 16.113,33.723 15.977,33.34 15.977,32.844 l 0,-17.692 c 0,-0.496 0.136,-0.879 0.371,-1.125 l 0.058,-0.054 9.914,9.91 0,0.234 -9.914,9.91 -0.058,-0.058 z" + style="fill:url(#linearGradient50);fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path64" + inkscape:connector-curvature="0" /></g><g + id="g66"><path + d="m 29.621,20.578 -3.301,3.305 0,0.234 3.305,3.305 0.074,-0.043 3.914,-2.227 c 1.117,-0.632 1.117,-1.672 0,-2.308 l -3.914,-2.223 -0.078,-0.043 z" + style="fill:url(#linearGradient68);fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path78" + inkscape:connector-curvature="0" /></g><g + id="g80"><path + d="M 29.699,20.621 26.32,24 16.348,14.027 c 0.371,-0.39 0.976,-0.437 1.664,-0.047 l 11.687,6.641" + style="fill:url(#linearGradient82);fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path88" + inkscape:connector-curvature="0" /></g><g + id="g90"><path + d="M 29.699,27.379 18.012,34.02 c -0.688,0.386 -1.293,0.339 -1.664,-0.051 L 26.32,24 l 3.379,3.379 z" + style="fill:url(#linearGradient92);fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path104" + inkscape:connector-curvature="0" /></g><g + id="g106"><g + id="g108" /><g + id="g122" + mask="url(#mask114)"><g + id="g124" /><g + id="g146"><g + clip-path="url(#clipPath126)" + id="g148"><g + id="g150"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + style="fill:url(#pattern134);fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path152" + inkscape:connector-curvature="0" /></g></g></g></g></g><g + id="g154"><g + id="g156" /><g + id="g170" + mask="url(#mask162)"><g + id="g172" /><g + id="g194"><g + clip-path="url(#clipPath174)" + id="g196"><g + id="g198"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + style="fill:url(#pattern182);fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path200" + inkscape:connector-curvature="0" /></g></g></g></g></g><g + id="g202"><g + id="g204" /><g + id="g218" + mask="url(#mask210)"><g + id="g220" /><g + id="g242"><g + clip-path="url(#clipPath222)" + id="g244"><g + id="g246"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + style="fill:url(#pattern230);fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path248" + inkscape:connector-curvature="0" /></g></g></g></g></g><g + id="g250"><g + id="g252" /><g + id="g266" + mask="url(#mask258)"><g + id="g268" /><g + id="g290"><g + clip-path="url(#clipPath270)" + id="g292"><g + id="g294"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + style="fill:url(#pattern278);fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path296" + inkscape:connector-curvature="0" /></g></g></g></g></g></g></g></svg> \ No newline at end of file diff --git a/themes/squares/client/src/img/success.png b/themes/squares/client/src/img/success.png new file mode 100644 index 0000000000000000000000000000000000000000..ee9d6841bbb7208220a831c1c42917eb8facffa5 GIT binary patch literal 3147 zcmV-R47Br!P)<h;3K|Lk000e1NJLTq004jh004jp1^@s6!#-il00004b3#c}2nYxW zd<bNS00009a7bBm000Ai000Ai0a;&)sQ>@~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+<QJB-o5uMzdHs1##k+{?Mn?ISwOOaB#k7O zq=ck`q?Y75iG!q%#zFtAmj0U(`hU{s*TAn8V#U6)0_<RtWh9v-Wh5;mz$h*Bdt}n@ z87u`b9{6~YXp)U2l_dQPf%{57J%$bRn8r&1*nwY2vWcXFBXD2opvSmS3ZM`4D3Vl? zdV!&jlzMvXQBnZ);PIxbh-A=&;5{=)&t-uWz&H3MB&8OBK2q@9mPi4HgO4F8w=j6m z;JL;Gya1#VB1rNB2)t+T+#>=`0CLjfNNgnC0Sw=jZrT^)0zv?CqKirDB=}*$K3Qy8 z0m#XXB}uo)ojuDZ2JEA;mJ)zo>nkK+#i=mr-oOa>%SoIPs_I~$EjO(I<luuyGUP|T z=1T(hVUVc=pc6KB2~fA#Sxq#tfIop`pM<7+uumrlEdV*dP?8f87PDaAh6*VFIj~5R z&f1xAmN8N&0q9S@gm0__p+X2iule$0pU>rB0#^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 z<!m1Otm-59+WtSWUTqwqhXDB!p0@nv#J}NccRP5tzJZ5O+<X$=_-rGyeFvHZpgVRX zL_f3ez`Ky&R1JgV*J!Q1w+r4m^$Dxvh|wZIxdiDy{3_=aP}&|14nX1=JCki_ehEM~ zYD$=X!au44z5x;}@kfAC2~&N(abP_hb6y318zTuyRS7`%=S!ge!0+%MK4S?Ms1cw@ z0#y&cy5kz?)fh-nq(}f-mPHQvs>nAFu7~3~!grS-_H?$ML&Q-^1W1)o)xkf}aUEDS zXKy#WS(dKX@un&fK(6jp0srJ3cJN)TsPU9;tBFSyfR<vDa1~_H!8AkQyV~qx-Hv_X zXaa1KaFxPu03+M7(!ax$t#R<`Ud_k<Pp<;dCEgtpuy6RM42F*_p}G)0|H(?2Y>$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|{4<?5xxzoxQa>VmS0-$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;O<yRCk~cO|MY zLCLwc3<>bs;}<L$*1`X6IIOBOZr<-OOag>|&gAf2c_BYu$vKA@y7)sad{=9{?}TNC zGnpRSw-7%0#NpX45cpABwZnHMY?}>U^|43<1y~_6d{?`1-vQrcBmu%d&l&#tyDhrH zcjfuf=MPq(0BNGbe<DGT1HN-8!;<hLx2EV0-<2<H8FA=14F$+G1AJF&=(!C`b&>#g z>Rjl$#TEX!JGYp^cjejK)$o<!66B%)C9L3oSYOJd)2{Wj6A9ke4F944@Tb|=Fo*BT zGugxD4@$<cD){i@%D)Xg&$DW{)yiK95dKJG5Jt5+gW*4!Kgf!Oxg}4Ajw?`rS{4Er zF^YDzyG`||p$PELb~Hl-2l!s*4{A|>>w3e-M>eC{6(T{j!bOv~xH8b;KQ(l`Gr0`~ zaOf?-%9?CLJN0sRtIw6Q`1keqTDihMBMAH_5|E$(eR>P<LH!=%Cr<8-ECGH(pd>t7 zZATNlU{4kXzAH=wz#T1ICQ!)7J6_{G;p1gO!uLLn%;XaA!-^01&7!$UfH#P|;h%Ok zLHHK&;M2##0VX$rU)}ddXnt^q-<UdizW>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}0Y5i<Q7B5FM<FE%UwcCBadWvW3)j1# z5|4Jw<nSkE#SW0)gSc%BVO?8?Wjoq_9iGokFfDxg(|;RyZw-4~W2eDX@T=OdnI8Vo z6F`3aUJzk(D6fhee#6f(KYYy0?Mc4^QMQLK;#_<|NvpIF{E+NbgHLS_2LTF1U<D4| z`|w9<N7}Ah20muW*0^>Mu|$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?<cXeSeeyD`N&4Y_D_vg$bLLuvIF+ zIFdRkfoW#^I+2-f0#FGSo8_Itfh+-*fSbK5`TI(_Di(lBkZzXOy#%TRk%bAxYM81O zU@Xgm5>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~ErrWYk<k!$=+k^5L^0lbv3+ayXcOK%cP zw#W68JNg6U`u?U_`qeZ7cuX`!)7r+BJgb}&3Hrmf*;>Q%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/squares/client/src/img/user.png b/themes/squares/client/src/img/user.png new file mode 100644 index 0000000000000000000000000000000000000000..00941399d34f0f2d85d323f61daa67c2c00c494d GIT binary patch literal 2933 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®tx`|Cc!K{R~B2jc1N|ng#YPY z01+^g*}>)+v;a(kg*ZQ7)0sYS5YJw62)S4!2N7^)@}f|(3`qbcL4r0*!k9b<Kf36E z+dHp_;d2t<{ZMlZSpX)%4mEG<wr6D9I^1^#jj}wPP9kK5lVP|5;6-6hxHEe<eQ=<l zvF(dqqXn;n2p6%BhMNFPf@q*Llm@KcnC=vD1u!Ba;N$2q;pP{%08E1Cfbu|#e``%@ z4c<RTTJS20(CmA-lY{_@4XBGITju5T1zTyP2Op*gpQ#nTssb<xV*GU!!Ik~M{-Yl| zx_bMDWZ(gbFy5pST-9}knwhC80F&TJTz<q?G%lOg(9+o}0}dQlujip9-HPK3UPbty z;(}gk=cT#;zR|<jc=NG6AD%7f?i+X{Lk*4MTeS_#S6Q0aYOYhY$Vw9d5NN<-z(zeM zY#NZE&h9&#$3D&_ga8T=<^nY`%%H&qFn*zRKAH=_B!~tw0r_<h82f;y^vFgR0Td$q z2I!Unj|(1~-|Cr(t^zO#o(0ayu)o0>JT`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}b2r<A0z$IYtoxpm%?&l3DfI@^PfQ9V4lWIT0 zFn~kdX5dw<S%hONgaBh?cpUf@kO9<j?p?$+3J(B_sGi~tCx9Ozo(A3kQh<Do`xd@r z9X6|}T*XJc0OO28&H@$yaexIl1>kn{6#(u#g!>KLW&ICB?B8ANXZSm~9Q{dlI$z1o fINvtfu|oa}IMcn@FFyTa00000NkvXXu0mjfV5=n> literal 0 HcmV?d00001 diff --git a/themes/squares/client/src/img/warning.png b/themes/squares/client/src/img/warning.png new file mode 100644 index 0000000000000000000000000000000000000000..c6acd953b142a8e43e293c353a60e6313a8d39eb GIT binary patch literal 4038 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 F{{xrIJYfI; literal 0 HcmV?d00001 diff --git a/themes/squares/client/src/index.ts b/themes/squares/client/src/index.ts new file mode 100644 index 00000000..802004a8 --- /dev/null +++ b/themes/squares/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 () { + (<any>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/squares/client/src/lib/GetPromised.ts b/themes/squares/client/src/lib/GetPromised.ts new file mode 100644 index 00000000..77913965 --- /dev/null +++ b/themes/squares/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<any> { + return new BluebirdPromise<any>((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/squares/client/src/lib/INotifier.ts b/themes/squares/client/src/lib/INotifier.ts new file mode 100644 index 00000000..df947538 --- /dev/null +++ b/themes/squares/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/squares/client/src/lib/Notifier.ts b/themes/squares/client/src/lib/Notifier.ts new file mode 100644 index 00000000..c0252b9b --- /dev/null +++ b/themes/squares/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('<i><img src="/img/notifications/%s.png" alt="status %s"/></i>\ + <span>%s</span>', 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/squares/client/src/lib/QueryParametersRetriever.ts b/themes/squares/client/src/lib/QueryParametersRetriever.ts new file mode 100644 index 00000000..a529adb6 --- /dev/null +++ b/themes/squares/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/squares/client/src/lib/SafeRedirect.ts b/themes/squares/client/src/lib/SafeRedirect.ts new file mode 100644 index 00000000..7e7684b8 --- /dev/null +++ b/themes/squares/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/squares/client/src/lib/firstfactor/FirstFactorValidator.ts b/themes/squares/client/src/lib/firstfactor/FirstFactorValidator.ts new file mode 100644 index 00000000..eaa496fd --- /dev/null +++ b/themes/squares/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<string> { + return new BluebirdPromise<string>(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/squares/client/src/lib/firstfactor/UISelectors.ts b/themes/squares/client/src/lib/firstfactor/UISelectors.ts new file mode 100644 index 00000000..0e971b3c --- /dev/null +++ b/themes/squares/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/squares/client/src/lib/firstfactor/index.ts b/themes/squares/client/src/lib/firstfactor/index.ts new file mode 100644 index 00000000..24affee2 --- /dev/null +++ b/themes/squares/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/squares/client/src/lib/reset-password/constants.ts b/themes/squares/client/src/lib/reset-password/constants.ts new file mode 100644 index 00000000..d48d4e67 --- /dev/null +++ b/themes/squares/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/squares/client/src/lib/reset-password/reset-password-form.ts b/themes/squares/client/src/lib/reset-password/reset-password-form.ts new file mode 100644 index 00000000..b94279cd --- /dev/null +++ b/themes/squares/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/squares/client/src/lib/reset-password/reset-password-request.ts b/themes/squares/client/src/lib/reset-password/reset-password-request.ts new file mode 100644 index 00000000..846226d7 --- /dev/null +++ b/themes/squares/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/squares/client/src/lib/secondfactor/TOTPValidator.ts b/themes/squares/client/src/lib/secondfactor/TOTPValidator.ts new file mode 100644 index 00000000..5394139a --- /dev/null +++ b/themes/squares/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<string> { + return new BluebirdPromise<string>(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/squares/client/src/lib/secondfactor/U2FValidator.ts b/themes/squares/client/src/lib/secondfactor/U2FValidator.ts new file mode 100644 index 00000000..5812922f --- /dev/null +++ b/themes/squares/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<string> { + return new BluebirdPromise<string>(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<string> { + 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/squares/client/src/lib/secondfactor/constants.ts b/themes/squares/client/src/lib/secondfactor/constants.ts new file mode 100644 index 00000000..50bba757 --- /dev/null +++ b/themes/squares/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/squares/client/src/lib/secondfactor/index.ts b/themes/squares/client/src/lib/secondfactor/index.ts new file mode 100644 index 00000000..279723dc --- /dev/null +++ b/themes/squares/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/squares/client/src/lib/totp-register/totp-register.ts b/themes/squares/client/src/lib/totp-register/totp-register.ts new file mode 100644 index 00000000..6a9aa7ee --- /dev/null +++ b/themes/squares/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/squares/client/src/lib/totp-register/ui-selector.ts b/themes/squares/client/src/lib/totp-register/ui-selector.ts new file mode 100644 index 00000000..9d43fabe --- /dev/null +++ b/themes/squares/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/squares/client/src/lib/u2f-register/u2f-register.ts b/themes/squares/client/src/lib/u2f-register/u2f-register.ts new file mode 100644 index 00000000..abf40ee0 --- /dev/null +++ b/themes/squares/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<string> { + return new BluebirdPromise<string>(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<string> { + 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/squares/client/src/thirdparties/qrcode.min.js b/themes/squares/client/src/thirdparties/qrcode.min.js new file mode 100644 index 00000000..993e88f3 --- /dev/null +++ b/themes/squares/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<a.length&&0==a[c];)c++;this.num=new Array(a.length-c+b);for(var d=0;d<a.length-c;d++)this.num[d]=a[d+c]}function j(a,b){this.totalCount=a,this.dataCount=b}function k(){this.buffer=[],this.length=0}function m(){return"undefined"!=typeof CanvasRenderingContext2D}function n(){var a=!1,b=navigator.userAgent;return/android/i.test(b)&&(a=!0,aMat=b.toString().match(/android ([0-9]\.[0-9])/i),aMat&&aMat[1]&&(a=parseFloat(aMat[1]))),a}function r(a,b){for(var c=1,e=s(a),f=0,g=l.length;g>=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<this.moduleCount;d++){this.modules[d]=new Array(this.moduleCount);for(var e=0;e<this.moduleCount;e++)this.modules[d][e]=null}this.setupPositionProbePattern(0,0),this.setupPositionProbePattern(this.moduleCount-7,0),this.setupPositionProbePattern(0,this.moduleCount-7),this.setupPositionAdjustPattern(),this.setupTimingPattern(),this.setupTypeInfo(a,c),this.typeNumber>=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<this.modules.length;f++)for(var g=f*e,h=0;h<this.modules[f].length;h++){var i=h*e,j=this.modules[f][h];j&&(d.beginFill(0,100),d.moveTo(i,g),d.lineTo(i+e,g),d.lineTo(i+e,g+e),d.lineTo(i,g+e),d.endFill())}return d},setupTimingPattern:function(){for(var a=8;a<this.moduleCount-8;a++)null==this.modules[a][6]&&(this.modules[a][6]=0==a%2);for(var b=8;b<this.moduleCount-8;b++)null==this.modules[6][b]&&(this.modules[6][b]=0==b%2)},setupPositionAdjustPattern:function(){for(var a=f.getPatternPosition(this.typeNumber),b=0;b<a.length;b++)for(var c=0;c<a.length;c++){var d=a[b],e=a[c];if(null==this.modules[d][e])for(var g=-2;2>=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<a.length&&(j=1==(1&a[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;h<d.length;h++){var i=d[h];g.put(i.mode,4),g.put(i.getLength(),f.getLengthInBits(i.mode,a)),i.write(g)}for(var l=0,h=0;h<e.length;h++)l+=e[h].dataCount;if(g.getLengthInBits()>8*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<b.length;j++){var k=b[j].dataCount,l=b[j].totalCount-k;d=Math.max(d,k),e=Math.max(e,l),g[j]=new Array(k);for(var m=0;m<g[j].length;m++)g[j][m]=255&a.buffer[m+c];c+=k;var n=f.getErrorCorrectPolynomial(l),o=new i(g[j],n.getLength()-1),p=o.mod(n);h[j]=new Array(n.getLength()-1);for(var m=0;m<h[j].length;m++){var q=m+p.getLength()-h[j].length;h[j][m]=q>=0?p.get(q):0}}for(var r=0,m=0;m<b.length;m++)r+=b[m].totalCount;for(var s=new Array(r),t=0,m=0;d>m;m++)for(var j=0;j<b.length;j++)m<g[j].length&&(s[t++]=g[j][m]);for(var m=0;e>m;m++)for(var j=0;j<b.length;j++)m<h[j].length&&(s[t++]=h[j][m]);return s};for(var c={MODE_NUMBER:1,MODE_ALPHA_NUM:2,MODE_8BIT_BYTE:4,MODE_KANJI:8},d={L:1,M:0,Q:3,H:2},e={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7},f={PATTERN_POSITION_TABLE:[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],G15:1335,G18:7973,G15_MASK:21522,getBCHTypeInfo:function(a){for(var b=a<<10;f.getBCHDigit(b)-f.getBCHDigit(f.G15)>=0;)b^=f.G15<<f.getBCHDigit(b)-f.getBCHDigit(f.G15);return(a<<10|b)^f.G15_MASK},getBCHTypeNumber:function(a){for(var b=a<<12;f.getBCHDigit(b)-f.getBCHDigit(f.G18)>=0;)b^=f.G18<<f.getBCHDigit(b)-f.getBCHDigit(f.G18);return a<<12|b},getBCHDigit:function(a){for(var b=0;0!=a;)b++,a>>>=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;for(var h=8;256>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;c<this.getLength();c++)for(var d=0;d<a.getLength();d++)b[c+d]^=g.gexp(g.glog(this.get(c))+g.glog(a.get(d)));return new i(b,0)},mod:function(a){if(this.getLength()-a.getLength()<0)return this;for(var b=g.glog(this.get(0))-g.glog(a.get(0)),c=new Array(this.getLength()),d=0;d<this.getLength();d++)c[d]=this.get(d);for(var d=0;d<a.getLength();d++)c[d]^=g.gexp(g.glog(a.get(d))+b);return new i(c,0).mod(a)}},j.RS_BLOCK_TABLE=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]],j.getRSBlocks=function(a,b){var c=j.getRsBlockTable(a,b);if(void 0==c)throw new Error("bad rs block @ typeNumber:"+a+"/errorCorrectLevel:"+b);for(var d=c.length/3,e=[],f=0;d>f;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=['<table style="border:0;border-collapse:collapse;">'],h=0;d>h;h++){g.push("<tr>");for(var i=0;d>i;i++)g.push('<td style="border:0;border-collapse:collapse;padding:0;margin:0;width:'+e+"px;height:"+f+"px;background-color:"+(a.isDark(h,i)?b.colorDark:b.colorLight)+';"></td>');g.push("</tr>")}g.push("</table>"),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/squares/client/src/thirdparties/u2f-api.js b/themes/squares/client/src/thirdparties/u2f-api.js new file mode 100644 index 00000000..8c7801e3 --- /dev/null +++ b/themes/squares/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.Transport>} + */ +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<u2f.SignRequest>} 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<u2f.SignRequest>} signRequests + * @param {Array<u2f.RegisterRequest>} 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<function((MessagePort|u2f.WrappedChromeRuntimePort_))>} + * @private + */ +u2f.waitingForPort_ = []; + +/** + * A counter for requestIds. + * @type {number} + * @private + */ +u2f.reqCounter_ = 0; + +/** + * A map from requestIds to client callbacks + * @type {Object.<number,(function((u2f.Error|u2f.RegisterResponse)) + * |function((u2f.Error|u2f.SignResponse)))>} + * @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.<u2f.Response>} 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<u2f.RegisteredKey>} 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<u2f.RegisteredKey>} 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<u2f.RegisterRequest>} registerRequests + * @param {Array<u2f.RegisteredKey>} 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<u2f.RegisterRequest>} registerRequests + * @param {Array<u2f.RegisteredKey>} 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/squares/client/test/Notifier.test.ts b/themes/squares/client/test/Notifier.test.ts new file mode 100644 index 00000000..70bfea14 --- /dev/null +++ b/themes/squares/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/squares/client/test/firstfactor/FirstFactorValidator.test.ts b/themes/squares/client/test/firstfactor/FirstFactorValidator.test.ts new file mode 100644 index 00000000..ac835327 --- /dev/null +++ b/themes/squares/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/squares/client/test/mocks/NotifierStub.ts b/themes/squares/client/test/mocks/NotifierStub.ts new file mode 100644 index 00000000..9c268d66 --- /dev/null +++ b/themes/squares/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/squares/client/test/mocks/jquery.ts b/themes/squares/client/test/mocks/jquery.ts new file mode 100644 index 00000000..273f9086 --- /dev/null +++ b/themes/squares/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/squares/client/test/mocks/u2f-api.ts b/themes/squares/client/test/mocks/u2f-api.ts new file mode 100644 index 00000000..d123f6a9 --- /dev/null +++ b/themes/squares/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/squares/client/test/secondfactor/TOTPValidator.test.ts b/themes/squares/client/test/secondfactor/TOTPValidator.test.ts new file mode 100644 index 00000000..5dd6f15c --- /dev/null +++ b/themes/squares/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/squares/client/test/totp-register/totp-register.test.ts b/themes/squares/client/test/totp-register/totp-register.test.ts new file mode 100644 index 00000000..86fc455a --- /dev/null +++ b/themes/squares/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/squares/client/tsconfig.json b/themes/squares/client/tsconfig.json new file mode 100644 index 00000000..0bb4d62f --- /dev/null +++ b/themes/squares/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/squares/client/tslint.json b/themes/squares/client/tslint.json new file mode 100644 index 00000000..c2c1b750 --- /dev/null +++ b/themes/squares/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/squares/server/.directory b/themes/squares/server/.directory new file mode 100644 index 00000000..b7754766 --- /dev/null +++ b/themes/squares/server/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,17,20,58,20 +Version=3 +ViewMode=1 diff --git a/themes/squares/server/src/index.ts b/themes/squares/server/src/index.ts new file mode 100755 index 00000000..fcbf4d02 --- /dev/null +++ b/themes/squares/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 <config>"); + 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/squares/server/src/lib/.directory b/themes/squares/server/src/lib/.directory new file mode 100644 index 00000000..006b379a --- /dev/null +++ b/themes/squares/server/src/lib/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,17,20,59,13 +Version=3 +ViewMode=1 diff --git a/themes/squares/server/src/lib/AuthenticationSessionHandler.ts b/themes/squares/server/src/lib/AuthenticationSessionHandler.ts new file mode 100644 index 00000000..57361bf8 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/ErrorReplies.ts b/themes/squares/server/src/lib/ErrorReplies.ts new file mode 100644 index 00000000..f1c5f4fd --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/Exceptions.ts b/themes/squares/server/src/lib/Exceptions.ts new file mode 100644 index 00000000..83fa4eb6 --- /dev/null +++ b/themes/squares/server/src/lib/Exceptions.ts @@ -0,0 +1,88 @@ + +export class LdapSearchError extends Error { + constructor(message?: string) { + super(message); + this.name = "LdapSearchError"; + (<any>Object).setPrototypeOf(this, LdapSearchError.prototype); + } +} + +export class LdapBindError extends Error { + constructor(message?: string) { + super(message); + this.name = "LdapBindError"; + (<any>Object).setPrototypeOf(this, LdapBindError.prototype); + } +} + +export class LdapError extends Error { + constructor(message?: string) { + super(message); + this.name = "LdapError"; + (<any>Object).setPrototypeOf(this, LdapError.prototype); + } +} + +export class IdentityError extends Error { + constructor(message?: string) { + super(message); + this.name = "IdentityError"; + (<any>Object).setPrototypeOf(this, IdentityError.prototype); + } +} + +export class AccessDeniedError extends Error { + constructor(message?: string) { + super(message); + this.name = "AccessDeniedError"; + (<any>Object).setPrototypeOf(this, AccessDeniedError.prototype); + } +} + +export class AuthenticationRegulationError extends Error { + constructor(message?: string) { + super(message); + this.name = "AuthenticationRegulationError"; + (<any>Object).setPrototypeOf(this, AuthenticationRegulationError.prototype); + } +} + +export class InvalidTOTPError extends Error { + constructor(message?: string) { + super(message); + this.name = "InvalidTOTPError"; + (<any>Object).setPrototypeOf(this, InvalidTOTPError.prototype); + } +} + +export class NotAuthenticatedError extends Error { + constructor(message?: string) { + super(message); + this.name = "NotAuthenticatedError"; + (<any>Object).setPrototypeOf(this, NotAuthenticatedError.prototype); + } +} + +export class NotAuthorizedError extends Error { + constructor(message?: string) { + super(message); + this.name = "NotAuthanticatedError"; + (<any>Object).setPrototypeOf(this, NotAuthorizedError.prototype); + } +} + +export class FirstFactorValidationError extends Error { + constructor(message?: string) { + super(message); + this.name = "FirstFactorValidationError"; + (<any>Object).setPrototypeOf(this, FirstFactorValidationError.prototype); + } +} + +export class SecondFactorValidationError extends Error { + constructor(message?: string) { + super(message); + this.name = "SecondFactorValidationError"; + (<any>Object).setPrototypeOf(this, FirstFactorValidationError.prototype); + } +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/FirstFactorValidator.ts b/themes/squares/server/src/lib/FirstFactorValidator.ts new file mode 100644 index 00000000..23106000 --- /dev/null +++ b/themes/squares/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<void> { + 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/squares/server/src/lib/IdentityCheckMiddleware.spec.ts b/themes/squares/server/src/lib/IdentityCheckMiddleware.spec.ts new file mode 100644 index 00000000..842ed6bc --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/IdentityCheckMiddleware.ts b/themes/squares/server/src/lib/IdentityCheckMiddleware.ts new file mode 100644 index 00000000..e72ea4db --- /dev/null +++ b/themes/squares/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<string> { + + 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<IdentityValidationDocument> { + 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<void> { + 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<void> { + + let authSession: AuthenticationSession; + const identityToken = objectPath.get<Express.Request, string>( + 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<void> { + 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/squares/server/src/lib/IdentityCheckPreValidationTemplate.ts b/themes/squares/server/src/lib/IdentityCheckPreValidationTemplate.ts new file mode 100644 index 00000000..0161ce40 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/IdentityValidable.ts b/themes/squares/server/src/lib/IdentityValidable.ts new file mode 100644 index 00000000..075580c9 --- /dev/null +++ b/themes/squares/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<Identity.Identity>; + postValidationInit(req: Express.Request): Bluebird<void>; + + // 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/squares/server/src/lib/IdentityValidableStub.spec.ts b/themes/squares/server/src/lib/IdentityValidableStub.spec.ts new file mode 100644 index 00000000..20a97714 --- /dev/null +++ b/themes/squares/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<Identity> { + return this.preValidationInitStub(req); + } + + postValidationInit(req: Express.Request): Bluebird<void> { + 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/squares/server/src/lib/Server.spec.ts b/themes/squares/server/src/lib/Server.spec.ts new file mode 100644 index 00000000..36516325 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/Server.ts b/themes/squares/server/src/lib/Server.ts new file mode 100644 index 00000000..4090f629 --- /dev/null +++ b/themes/squares/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<void> { + 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<void>((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<void> { + 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/squares/server/src/lib/ServerVariables.ts b/themes/squares/server/src/lib/ServerVariables.ts new file mode 100644 index 00000000..cd3dd6dc --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/ServerVariablesInitializer.ts b/themes/squares/server/src/lib/ServerVariablesInitializer.ts new file mode 100644 index 00000000..df79238c --- /dev/null +++ b/themes/squares/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<UserDataStore> { + 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<ServerVariables> { + + 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/squares/server/src/lib/ServerVariablesMockBuilder.spec.ts b/themes/squares/server/src/lib/ServerVariablesMockBuilder.spec.ts new file mode 100644 index 00000000..7874702a --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/Level.ts b/themes/squares/server/src/lib/authentication/Level.ts new file mode 100644 index 00000000..57b6a234 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/backends/GroupsAndEmails.ts b/themes/squares/server/src/lib/authentication/backends/GroupsAndEmails.ts new file mode 100644 index 00000000..3434ba66 --- /dev/null +++ b/themes/squares/server/src/lib/authentication/backends/GroupsAndEmails.ts @@ -0,0 +1,5 @@ + +export interface GroupsAndEmails { + groups: string[]; + emails: string[]; +} diff --git a/themes/squares/server/src/lib/authentication/backends/IUsersDatabase.ts b/themes/squares/server/src/lib/authentication/backends/IUsersDatabase.ts new file mode 100644 index 00000000..d7fa13b7 --- /dev/null +++ b/themes/squares/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<GroupsAndEmails>; + getEmails(username: string): Bluebird<string[]>; + getGroups(username: string): Bluebird<string[]>; + updatePassword(username: string, newPassword: string): Bluebird<void>; +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/authentication/backends/IUsersDatabaseStub.spec.ts b/themes/squares/server/src/lib/authentication/backends/IUsersDatabaseStub.spec.ts new file mode 100644 index 00000000..19341a5d --- /dev/null +++ b/themes/squares/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<GroupsAndEmails> { + return this.checkUserPasswordStub(username, password); + } + + getEmails(username: string): Bluebird<string[]> { + return this.getEmailsStub(username); + } + + getGroups(username: string): Bluebird<string[]> { + return this.getGroupsStub(username); + } + + updatePassword(username: string, newPassword: string): Bluebird<void> { + return this.updatePasswordStub(username, newPassword); + } +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/authentication/backends/file/FileUsersDatabase.spec.ts b/themes/squares/server/src/lib/authentication/backends/file/FileUsersDatabase.spec.ts new file mode 100644 index 00000000..a258a78f --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/backends/file/FileUsersDatabase.ts b/themes/squares/server/src/lib/authentication/backends/file/FileUsersDatabase.ts new file mode 100644 index 00000000..d34dde21 --- /dev/null +++ b/themes/squares/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<any> { + return new Bluebird<string>((resolve, reject) => { + this.queue.read((err: Error, data: string) => { + if (err) { + reject(err); + return; + } + resolve(data); + this.queue.next(); + }); + }) + .then((content) => { + const database = Yaml.parse(content); + if (!database) { + return Bluebird.reject(new Error("Unable to parse YAML file.")); + } + return Bluebird.resolve(database); + }); + } + + /** + * Checks the user exists in the database. + */ + private checkUserExists( + database: any, + username: string) + : Bluebird<void> { + if (!(username in database.users)) { + return Bluebird.reject( + new Error(`User ${username} does not exist in database.`)); + } + return Bluebird.resolve(); + } + + /** + * Check the password of a given user. + */ + private checkPassword( + database: any, + username: string, + password: string) + : Bluebird<void> { + const storedHash: string = database.users[username].password; + const matches = storedHash.match(/rounds=([0-9]+)\$([a-zA-z0-9]+)\$/); + if (!(matches && matches.length == 3)) { + return Bluebird.reject(new Error("Unable to detect the hash salt and rounds. " + + "Make sure the password is hashed with SSHA512.")); + } + + const rounds: number = parseInt(matches[1]); + const salt = matches[2]; + + return HashGenerator.ssha512(password, rounds, salt) + .then((hash: string) => { + if (hash !== storedHash) { + return Bluebird.reject(new Error("Wrong username/password.")); + } + return Bluebird.resolve(); + }); + } + + /** + * Retrieve email addresses of a given user. + */ + private retrieveEmails( + database: any, + username: string) + : Bluebird<string[]> { + if (!("email" in database.users[username])) { + return Bluebird.reject( + new Error(`User ${username} has no email address.`)); + } + return Bluebird.resolve( + [database.users[username].email]); + } + + private retrieveGroups( + database: any, + username: string) + : Bluebird<string[]> { + if (!("groups" in database.users[username])) { + return Bluebird.resolve([]); + } + return Bluebird.resolve( + database.users[username].groups); + } + + private replacePassword( + database: any, + username: string, + newPassword: string) + : Bluebird<void> { + const that = this; + return HashGenerator.ssha512(newPassword) + .then((hash) => { + database.users[username].password = hash; + const str = Yaml.stringify(database, 4, 2); + return Bluebird.resolve(str); + }) + .then((content: string) => { + return new Bluebird((resolve, reject) => { + that.queue.write(content, (err) => { + if (err) { + return reject(err); + } + resolve(); + that.queue.next(); + }); + }); + }); + } + + checkUserPassword( + username: string, + password: string) + : Bluebird<GroupsAndEmails> { + return this.readDatabase() + .then((database) => { + return this.checkUserExists(database, username) + .then(() => this.checkPassword(database, username, password)) + .then(() => { + return Bluebird.join( + this.retrieveEmails(database, username), + this.retrieveGroups(database, username) + ).spread((emails: string[], groups: string[]) => { + return { emails: emails, groups: groups }; + }); + }); + }); + } + + getEmails(username: string): Bluebird<string[]> { + return this.readDatabase() + .then((database) => { + return this.checkUserExists(database, username) + .then(() => this.retrieveEmails(database, username)); + }); + } + + getGroups(username: string): Bluebird<string[]> { + return this.readDatabase() + .then((database) => { + return this.checkUserExists(database, username) + .then(() => this.retrieveGroups(database, username)); + }); + } + + updatePassword(username: string, newPassword: string): Bluebird<void> { + return this.readDatabase() + .then((database) => { + return this.checkUserExists(database, username) + .then(() => this.replacePassword(database, username, newPassword)); + }); + } +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/authentication/backends/file/ReadWriteQueue.ts b/themes/squares/server/src/lib/authentication/backends/file/ReadWriteQueue.ts new file mode 100644 index 00000000..957ddaec --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/backends/ldap/ISession.ts b/themes/squares/server/src/lib/authentication/backends/ldap/ISession.ts new file mode 100644 index 00000000..da2c7443 --- /dev/null +++ b/themes/squares/server/src/lib/authentication/backends/ldap/ISession.ts @@ -0,0 +1,12 @@ + +import BluebirdPromise = require("bluebird"); + +export interface ISession { + open(): BluebirdPromise<void>; + close(): BluebirdPromise<void>; + + searchUserDn(username: string): BluebirdPromise<string>; + searchEmails(username: string): BluebirdPromise<string[]>; + searchGroups(username: string): BluebirdPromise<string[]>; + modifyPassword(username: string, newPassword: string): BluebirdPromise<void>; +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/authentication/backends/ldap/ISessionFactory.ts b/themes/squares/server/src/lib/authentication/backends/ldap/ISessionFactory.ts new file mode 100644 index 00000000..014d1eea --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.spec.ts b/themes/squares/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.spec.ts new file mode 100644 index 00000000..f4a6e630 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.ts b/themes/squares/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.ts new file mode 100644 index 00000000..edda62ec --- /dev/null +++ b/themes/squares/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<T> = (session: ISession) => Bluebird<T>; + +export class LdapUsersDatabase implements IUsersDatabase { + private sessionFactory: ISessionFactory; + private configuration: LdapConfiguration; + + constructor( + sessionFactory: ISessionFactory, + configuration: LdapConfiguration) { + this.sessionFactory = sessionFactory; + this.configuration = configuration; + } + + private withSession<T>( + username: string, + password: string, + cb: SessionCallback<T>): Bluebird<T> { + const session = this.sessionFactory.create(username, password); + return session.open() + .then(() => cb(session)) + .finally(() => session.close()); + } + + checkUserPassword(username: string, password: string): Bluebird<GroupsAndEmails> { + const that = this; + function verifyUserPassword(userDN: string) { + return that.withSession<void>( + 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<string[]> { + 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<string[]> { + 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<void> { + 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/squares/server/src/lib/authentication/backends/ldap/SafeSession.spec.ts b/themes/squares/server/src/lib/authentication/backends/ldap/SafeSession.spec.ts new file mode 100644 index 00000000..9dedfcb7 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/backends/ldap/SafeSession.ts b/themes/squares/server/src/lib/authentication/backends/ldap/SafeSession.ts new file mode 100644 index 00000000..57220906 --- /dev/null +++ b/themes/squares/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<void> { + return this.sesion.open(); + } + + close(): BluebirdPromise<void> { + return this.sesion.close(); + } + + searchGroups(username: string): BluebirdPromise<string[]> { + 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<string> { + 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<string[]> { + 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<void> { + 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/squares/server/src/lib/authentication/backends/ldap/Sanitizer.spec.ts b/themes/squares/server/src/lib/authentication/backends/ldap/Sanitizer.spec.ts new file mode 100644 index 00000000..9dd33fed --- /dev/null +++ b/themes/squares/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<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=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/squares/server/src/lib/authentication/backends/ldap/Sanitizer.ts b/themes/squares/server/src/lib/authentication/backends/ldap/Sanitizer.ts new file mode 100644 index 00000000..be74132a --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/backends/ldap/Session.spec.ts b/themes/squares/server/src/lib/authentication/backends/ldap/Session.spec.ts new file mode 100644 index 00000000..d55f6a80 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/backends/ldap/Session.ts b/themes/squares/server/src/lib/authentication/backends/ldap/Session.ts new file mode 100644 index 00000000..e0284b3c --- /dev/null +++ b/themes/squares/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<void> { + 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<void> { + 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<string> { + 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<string[]> { + 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<string> { + 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<string[]> { + 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<void> { + 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/squares/server/src/lib/authentication/backends/ldap/SessionFactory.ts b/themes/squares/server/src/lib/authentication/backends/ldap/SessionFactory.ts new file mode 100644 index 00000000..0b6c4bff --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/backends/ldap/SessionFactoryStub.spec.ts b/themes/squares/server/src/lib/authentication/backends/ldap/SessionFactoryStub.spec.ts new file mode 100644 index 00000000..face3930 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/backends/ldap/SessionStub.spec.ts b/themes/squares/server/src/lib/authentication/backends/ldap/SessionStub.spec.ts new file mode 100644 index 00000000..5faf2ba1 --- /dev/null +++ b/themes/squares/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<void> { + return this.openStub(); + } + + close(): Bluebird<void> { + return this.closeStub(); + } + + searchUserDn(username: string): Bluebird<string> { + return this.searchUserDnStub(username); + } + + searchEmails(username: string): Bluebird<string[]> { + return this.searchEmailsStub(username); + } + + searchGroups(username: string): Bluebird<string[]> { + return this.searchGroupsStub(username); + } + + modifyPassword(username: string, newPassword: string): Bluebird<void> { + return this.modifyPasswordStub(username, newPassword); + } +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/authentication/backends/ldap/connector/Connector.ts b/themes/squares/server/src/lib/authentication/backends/ldap/connector/Connector.ts new file mode 100644 index 00000000..2542ea7f --- /dev/null +++ b/themes/squares/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<void>; + unbindAsync(): Bluebird<void>; + searchAsync(base: string, query: LdapJs.SearchOptions): Bluebird<EventEmitter>; + modifyAsync(userdn: string, change: LdapJs.Change): Bluebird<void>; +} + +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<void> { + return this.client.bindAsync(username, password); + } + + unbindAsync(): Bluebird<void> { + return this.client.unbindAsync(); + } + + searchAsync(base: string, query: any): Bluebird<any[]> { + const that = this; + return this.client.searchAsync(base, query) + .then(function (res: EventEmitter) { + const doc: SearchEntry[] = []; + return new Bluebird<any[]>((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<void> { + return this.client.modifyAsync(dn, changeRequest); + } +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/authentication/backends/ldap/connector/ConnectorFactory.ts b/themes/squares/server/src/lib/authentication/backends/ldap/connector/ConnectorFactory.ts new file mode 100644 index 00000000..61fef07a --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/backends/ldap/connector/ConnectorFactoryStub.spec.ts b/themes/squares/server/src/lib/authentication/backends/ldap/connector/ConnectorFactoryStub.spec.ts new file mode 100644 index 00000000..d11fa638 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/backends/ldap/connector/ConnectorStub.spec.ts b/themes/squares/server/src/lib/authentication/backends/ldap/connector/ConnectorStub.spec.ts new file mode 100644 index 00000000..0b78225b --- /dev/null +++ b/themes/squares/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<void> { + return this.bindAsyncStub(username, password); + } + + unbindAsync(): BluebirdPromise<void> { + return this.unbindAsyncStub(); + } + + searchAsync(base: string, query: any): BluebirdPromise<any[]> { + return this.searchAsyncStub(base, query); + } + + modifyAsync(dn: string, changeRequest: any): BluebirdPromise<void> { + return this.modifyAsyncStub(dn, changeRequest); + } +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/authentication/backends/ldap/connector/IConnector.ts b/themes/squares/server/src/lib/authentication/backends/ldap/connector/IConnector.ts new file mode 100644 index 00000000..1e63ab19 --- /dev/null +++ b/themes/squares/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<void>; + unbindAsync(): Bluebird<void>; + searchAsync(base: string, query: any): Bluebird<any[]>; + modifyAsync(dn: string, changeRequest: any): Bluebird<void>; +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/authentication/backends/ldap/connector/IConnectorFactory.ts b/themes/squares/server/src/lib/authentication/backends/ldap/connector/IConnectorFactory.ts new file mode 100644 index 00000000..f9ed65ef --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/totp/ITotpHandler.ts b/themes/squares/server/src/lib/authentication/totp/ITotpHandler.ts new file mode 100644 index 00000000..d600d31e --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/totp/TotpHandler.spec.ts b/themes/squares/server/src/lib/authentication/totp/TotpHandler.spec.ts new file mode 100644 index 00000000..67cffa63 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/totp/TotpHandler.ts b/themes/squares/server/src/lib/authentication/totp/TotpHandler.ts new file mode 100644 index 00000000..dfab502a --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/totp/TotpHandlerStub.spec.ts b/themes/squares/server/src/lib/authentication/totp/TotpHandlerStub.spec.ts new file mode 100644 index 00000000..ea93330d --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/u2f/IU2fHandler.ts b/themes/squares/server/src/lib/authentication/u2f/IU2fHandler.ts new file mode 100644 index 00000000..b9b7d6f2 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/u2f/U2fHandler.ts b/themes/squares/server/src/lib/authentication/u2f/U2fHandler.ts new file mode 100644 index 00000000..bf3891e5 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authentication/u2f/U2fHandlerStub.spec.ts b/themes/squares/server/src/lib/authentication/u2f/U2fHandlerStub.spec.ts new file mode 100644 index 00000000..135d7eb0 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authorization/Authorizer.spec.ts b/themes/squares/server/src/lib/authorization/Authorizer.spec.ts new file mode 100644 index 00000000..58681404 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authorization/Authorizer.ts b/themes/squares/server/src/lib/authorization/Authorizer.ts new file mode 100644 index 00000000..889b7ec2 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authorization/AuthorizerStub.spec.ts b/themes/squares/server/src/lib/authorization/AuthorizerStub.spec.ts new file mode 100644 index 00000000..9bd6f4a8 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authorization/IAuthorizer.ts b/themes/squares/server/src/lib/authorization/IAuthorizer.ts new file mode 100644 index 00000000..fe7ba367 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authorization/Level.ts b/themes/squares/server/src/lib/authorization/Level.ts new file mode 100644 index 00000000..d1280261 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authorization/MultipleDomainMatcher.ts b/themes/squares/server/src/lib/authorization/MultipleDomainMatcher.ts new file mode 100644 index 00000000..64c647a4 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authorization/Object.ts b/themes/squares/server/src/lib/authorization/Object.ts new file mode 100644 index 00000000..5411b0d2 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/authorization/Subject.ts b/themes/squares/server/src/lib/authorization/Subject.ts new file mode 100644 index 00000000..310d6b4c --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/ConfigurationParser.spec.ts b/themes/squares/server/src/lib/configuration/ConfigurationParser.spec.ts new file mode 100644 index 00000000..60c0f618 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/ConfigurationParser.ts b/themes/squares/server/src/lib/configuration/ConfigurationParser.ts new file mode 100644 index 00000000..d92d163c --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/SessionConfigurationBuilder.spec.ts b/themes/squares/server/src/lib/configuration/SessionConfigurationBuilder.spec.ts new file mode 100644 index 00000000..d4a3093e --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/SessionConfigurationBuilder.ts b/themes/squares/server/src/lib/configuration/SessionConfigurationBuilder.ts new file mode 100644 index 00000000..6ce643d9 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/AclConfiguration.spec.ts b/themes/squares/server/src/lib/configuration/schema/AclConfiguration.spec.ts new file mode 100644 index 00000000..d1e2a03a --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/AclConfiguration.ts b/themes/squares/server/src/lib/configuration/schema/AclConfiguration.ts new file mode 100644 index 00000000..40401dd6 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.spec.ts b/themes/squares/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.spec.ts new file mode 100644 index 00000000..3ca86381 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.ts b/themes/squares/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.ts new file mode 100644 index 00000000..7f77f894 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/Configuration.ts b/themes/squares/server/src/lib/configuration/schema/Configuration.ts new file mode 100644 index 00000000..8d16a5fb --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/FileUsersDatabaseConfiguration.ts b/themes/squares/server/src/lib/configuration/schema/FileUsersDatabaseConfiguration.ts new file mode 100644 index 00000000..d19002ba --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/LdapConfiguration.spec.ts b/themes/squares/server/src/lib/configuration/schema/LdapConfiguration.spec.ts new file mode 100644 index 00000000..cc73d108 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/LdapConfiguration.ts b/themes/squares/server/src/lib/configuration/schema/LdapConfiguration.ts new file mode 100644 index 00000000..5dacb939 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/NotifierConfiguration.spec.ts b/themes/squares/server/src/lib/configuration/schema/NotifierConfiguration.spec.ts new file mode 100644 index 00000000..6c576e8e --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/NotifierConfiguration.ts b/themes/squares/server/src/lib/configuration/schema/NotifierConfiguration.ts new file mode 100644 index 00000000..7bcce15c --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/RegulationConfiguration.spec.ts b/themes/squares/server/src/lib/configuration/schema/RegulationConfiguration.spec.ts new file mode 100644 index 00000000..dce2caf4 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/RegulationConfiguration.ts b/themes/squares/server/src/lib/configuration/schema/RegulationConfiguration.ts new file mode 100644 index 00000000..117463f4 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/SessionConfiguration.spec.ts b/themes/squares/server/src/lib/configuration/schema/SessionConfiguration.spec.ts new file mode 100644 index 00000000..e5401083 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/SessionConfiguration.ts b/themes/squares/server/src/lib/configuration/schema/SessionConfiguration.ts new file mode 100644 index 00000000..2c88bb21 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/StorageConfiguration.spec.ts b/themes/squares/server/src/lib/configuration/schema/StorageConfiguration.spec.ts new file mode 100644 index 00000000..9d02a11b --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/StorageConfiguration.ts b/themes/squares/server/src/lib/configuration/schema/StorageConfiguration.ts new file mode 100644 index 00000000..47e356ef --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/TotpConfiguration.ts b/themes/squares/server/src/lib/configuration/schema/TotpConfiguration.ts new file mode 100644 index 00000000..68313563 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/configuration/schema/UserDatabaseConfiguration.ts b/themes/squares/server/src/lib/configuration/schema/UserDatabaseConfiguration.ts new file mode 100644 index 00000000..8008b483 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/connectors/mongo/IMongoClient.d.ts b/themes/squares/server/src/lib/connectors/mongo/IMongoClient.d.ts new file mode 100644 index 00000000..36cb4b8b --- /dev/null +++ b/themes/squares/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<MongoDB.Collection> +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/connectors/mongo/MongoClient.spec.ts b/themes/squares/server/src/lib/connectors/mongo/MongoClient.spec.ts new file mode 100644 index 00000000..ca0c6859 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/connectors/mongo/MongoClient.ts b/themes/squares/server/src/lib/connectors/mongo/MongoClient.ts new file mode 100644 index 00000000..d15731e9 --- /dev/null +++ b/themes/squares/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<void> { + 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<void> { + if (this.client) { + this.client.close(); + this.database = undefined; + this.client = undefined; + } + return Bluebird.resolve(); + } + + collection(name: string): Bluebird<MongoDB.Collection> { + 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/squares/server/src/lib/connectors/mongo/MongoClientStub.spec.ts b/themes/squares/server/src/lib/connectors/mongo/MongoClientStub.spec.ts new file mode 100644 index 00000000..1cfd48e3 --- /dev/null +++ b/themes/squares/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<MongoDB.Collection> { + return this.collectionStub(name); + } +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/logging/GlobalLogger.ts b/themes/squares/server/src/lib/logging/GlobalLogger.ts new file mode 100644 index 00000000..4da7acf4 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/logging/GlobalLoggerStub.spec.ts b/themes/squares/server/src/lib/logging/GlobalLoggerStub.spec.ts new file mode 100644 index 00000000..d4bb1371 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/logging/IGlobalLogger.ts b/themes/squares/server/src/lib/logging/IGlobalLogger.ts new file mode 100644 index 00000000..548515ec --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/logging/IRequestLogger.ts b/themes/squares/server/src/lib/logging/IRequestLogger.ts new file mode 100644 index 00000000..126a601f --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/logging/RequestLogger.ts b/themes/squares/server/src/lib/logging/RequestLogger.ts new file mode 100644 index 00000000..c45c6601 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/logging/RequestLoggerStub.spec.ts b/themes/squares/server/src/lib/logging/RequestLoggerStub.spec.ts new file mode 100644 index 00000000..b0e37521 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/notifiers/AbstractEmailNotifier.ts b/themes/squares/server/src/lib/notifiers/AbstractEmailNotifier.ts new file mode 100644 index 00000000..198e4e5d --- /dev/null +++ b/themes/squares/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<void> { + 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<void>; +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/notifiers/EmailNotifier.spec.ts b/themes/squares/server/src/lib/notifiers/EmailNotifier.spec.ts new file mode 100644 index 00000000..8211bbc0 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/notifiers/EmailNotifier.ts b/themes/squares/server/src/lib/notifiers/EmailNotifier.ts new file mode 100644 index 00000000..4df7c861 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/notifiers/FileSystemNotifier.ts b/themes/squares/server/src/lib/notifiers/FileSystemNotifier.ts new file mode 100644 index 00000000..23f6242c --- /dev/null +++ b/themes/squares/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<void> { + 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/squares/server/src/lib/notifiers/IMailSender.ts b/themes/squares/server/src/lib/notifiers/IMailSender.ts new file mode 100644 index 00000000..34ac464a --- /dev/null +++ b/themes/squares/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<void>; +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/notifiers/IMailSenderBuilder.ts b/themes/squares/server/src/lib/notifiers/IMailSenderBuilder.ts new file mode 100644 index 00000000..36d4dcdf --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/notifiers/INotifier.ts b/themes/squares/server/src/lib/notifiers/INotifier.ts new file mode 100644 index 00000000..b9a6b138 --- /dev/null +++ b/themes/squares/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<void>; +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/notifiers/MailSender.ts b/themes/squares/server/src/lib/notifiers/MailSender.ts new file mode 100644 index 00000000..536a88e6 --- /dev/null +++ b/themes/squares/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<void> { + 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<void> { + 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/squares/server/src/lib/notifiers/MailSenderBuilder.spec.ts b/themes/squares/server/src/lib/notifiers/MailSenderBuilder.spec.ts new file mode 100644 index 00000000..41e0db42 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/notifiers/MailSenderBuilder.ts b/themes/squares/server/src/lib/notifiers/MailSenderBuilder.ts new file mode 100644 index 00000000..1d06be52 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/notifiers/MailSenderBuilderStub.spec.ts b/themes/squares/server/src/lib/notifiers/MailSenderBuilderStub.spec.ts new file mode 100644 index 00000000..5b76f6e5 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/notifiers/MailSenderStub.spec.ts b/themes/squares/server/src/lib/notifiers/MailSenderStub.spec.ts new file mode 100644 index 00000000..d57c458f --- /dev/null +++ b/themes/squares/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<void> { + return this.sendStub(mailOptions); + } +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/notifiers/NotifierFactory.spec.ts b/themes/squares/server/src/lib/notifiers/NotifierFactory.spec.ts new file mode 100644 index 00000000..f15e7667 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/notifiers/NotifierFactory.ts b/themes/squares/server/src/lib/notifiers/NotifierFactory.ts new file mode 100644 index 00000000..a89155fe --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/notifiers/NotifierStub.spec.ts b/themes/squares/server/src/lib/notifiers/NotifierStub.spec.ts new file mode 100644 index 00000000..f99231b5 --- /dev/null +++ b/themes/squares/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<void> { + return this.notifyStub(to, subject, link); + } +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/notifiers/SmtpNotifier.ts b/themes/squares/server/src/lib/notifiers/SmtpNotifier.ts new file mode 100644 index 00000000..f93a6d4a --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/regulation/IRegulator.ts b/themes/squares/server/src/lib/regulation/IRegulator.ts new file mode 100644 index 00000000..c49425b2 --- /dev/null +++ b/themes/squares/server/src/lib/regulation/IRegulator.ts @@ -0,0 +1,6 @@ +import BluebirdPromise = require("bluebird"); + +export interface IRegulator { + mark(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise<void>; + regulate(userId: string): BluebirdPromise<void>; +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/regulation/Regulator.spec.ts b/themes/squares/server/src/lib/regulation/Regulator.spec.ts new file mode 100644 index 00000000..f9c6e608 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/regulation/Regulator.ts b/themes/squares/server/src/lib/regulation/Regulator.ts new file mode 100644 index 00000000..1037a6a1 --- /dev/null +++ b/themes/squares/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<void> { + return this.userDataStore.saveAuthenticationTrace(userId, isAuthenticationSuccessful); + } + + regulate(userId: string): BluebirdPromise<void> { + 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/squares/server/src/lib/regulation/RegulatorStub.spec.ts b/themes/squares/server/src/lib/regulation/RegulatorStub.spec.ts new file mode 100644 index 00000000..ca8a00fb --- /dev/null +++ b/themes/squares/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<void> { + return this.markStub(userId, isAuthenticationSuccessful); + } + + regulate(userId: string): Bluebird<void> { + return this.regulateStub(userId); + } +} diff --git a/themes/squares/server/src/lib/routes/error/401/get.spec.ts b/themes/squares/server/src/lib/routes/error/401/get.spec.ts new file mode 100644 index 00000000..9fdac9c3 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/error/401/get.ts b/themes/squares/server/src/lib/routes/error/401/get.ts new file mode 100644 index 00000000..ca4a3963 --- /dev/null +++ b/themes/squares/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<void> { + const redirectionUrl = redirector(req, vars); + res.render("errors/401", { + redirection_url: redirectionUrl + }); + return BluebirdPromise.resolve(); + }; +} diff --git a/themes/squares/server/src/lib/routes/error/403/get.spec.ts b/themes/squares/server/src/lib/routes/error/403/get.spec.ts new file mode 100644 index 00000000..22eb8485 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/error/403/get.ts b/themes/squares/server/src/lib/routes/error/403/get.ts new file mode 100644 index 00000000..3ab0319e --- /dev/null +++ b/themes/squares/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<void> { + 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/squares/server/src/lib/routes/error/404/get.spec.ts b/themes/squares/server/src/lib/routes/error/404/get.spec.ts new file mode 100644 index 00000000..73e4e6ce --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/error/404/get.ts b/themes/squares/server/src/lib/routes/error/404/get.ts new file mode 100644 index 00000000..6693b6fc --- /dev/null +++ b/themes/squares/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<void> { + res.render("errors/404"); + return BluebirdPromise.resolve(); +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/routes/error/redirector.ts b/themes/squares/server/src/lib/routes/error/redirector.ts new file mode 100644 index 00000000..b1a3ccc1 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/firstfactor/get.ts b/themes/squares/server/src/lib/routes/firstfactor/get.ts new file mode 100644 index 00000000..d94f656c --- /dev/null +++ b/themes/squares/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<void> { + 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/squares/server/src/lib/routes/firstfactor/post.spec.ts b/themes/squares/server/src/lib/routes/firstfactor/post.spec.ts new file mode 100644 index 00000000..e1d078cd --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/firstfactor/post.ts b/themes/squares/server/src/lib/routes/firstfactor/post.ts new file mode 100644 index 00000000..565681d6 --- /dev/null +++ b/themes/squares/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<void> { + 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/squares/server/src/lib/routes/loggedin/get.ts b/themes/squares/server/src/lib/routes/loggedin/get.ts new file mode 100644 index 00000000..283a041b --- /dev/null +++ b/themes/squares/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<void> { + return new BluebirdPromise<void>(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/squares/server/src/lib/routes/logout/get.ts b/themes/squares/server/src/lib/routes/logout/get.ts new file mode 100644 index 00000000..4d511214 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/password-reset/constants.ts b/themes/squares/server/src/lib/routes/password-reset/constants.ts new file mode 100644 index 00000000..5c639e92 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/password-reset/form/post.spec.ts b/themes/squares/server/src/lib/routes/password-reset/form/post.spec.ts new file mode 100644 index 00000000..ed029c90 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/password-reset/form/post.ts b/themes/squares/server/src/lib/routes/password-reset/form/post.ts new file mode 100644 index 00000000..fccd7471 --- /dev/null +++ b/themes/squares/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<void> { + let authSession: AuthenticationSession; + const newPassword = objectPath.get<express.Request, string>(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/squares/server/src/lib/routes/password-reset/identity/PasswordResetHandler.spec.ts b/themes/squares/server/src/lib/routes/password-reset/identity/PasswordResetHandler.spec.ts new file mode 100644 index 00000000..ac6a4175 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/password-reset/identity/PasswordResetHandler.ts b/themes/squares/server/src/lib/routes/password-reset/identity/PasswordResetHandler.ts new file mode 100644 index 00000000..42ae92cd --- /dev/null +++ b/themes/squares/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<Identity> { + const that = this; + const userid: string = + objectPath.get<express.Request, string>(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/squares/server/src/lib/routes/password-reset/request/get.ts b/themes/squares/server/src/lib/routes/password-reset/request/get.ts new file mode 100644 index 00000000..8f3ae2b4 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/secondfactor/get.spec.ts b/themes/squares/server/src/lib/routes/secondfactor/get.spec.ts new file mode 100644 index 00000000..6c77e1f6 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/secondfactor/get.ts b/themes/squares/server/src/lib/routes/secondfactor/get.ts new file mode 100644 index 00000000..9f6deb4c --- /dev/null +++ b/themes/squares/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<void> { + + 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/squares/server/src/lib/routes/secondfactor/redirect.spec.ts b/themes/squares/server/src/lib/routes/secondfactor/redirect.spec.ts new file mode 100644 index 00000000..ea66e6dc --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/secondfactor/redirect.ts b/themes/squares/server/src/lib/routes/secondfactor/redirect.ts new file mode 100644 index 00000000..5d84d9eb --- /dev/null +++ b/themes/squares/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<void> { + + return new BluebirdPromise<void>(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/squares/server/src/lib/routes/secondfactor/totp/constants.ts b/themes/squares/server/src/lib/routes/secondfactor/totp/constants.ts new file mode 100644 index 00000000..7b5a1dcf --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.spec.ts b/themes/squares/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.spec.ts new file mode 100644 index 00000000..78b8ea3e --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.ts b/themes/squares/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.ts new file mode 100644 index 00000000..b39b6d04 --- /dev/null +++ b/themes/squares/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<Identity> { + 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<Identity> { + 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<void> { + 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/squares/server/src/lib/routes/secondfactor/totp/sign/post.spec.ts b/themes/squares/server/src/lib/routes/secondfactor/totp/sign/post.spec.ts new file mode 100644 index 00000000..70a20d39 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/secondfactor/totp/sign/post.ts b/themes/squares/server/src/lib/routes/secondfactor/totp/sign/post.ts new file mode 100644 index 00000000..34a276d1 --- /dev/null +++ b/themes/squares/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<void> { + 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/squares/server/src/lib/routes/secondfactor/u2f/U2FCommon.ts b/themes/squares/server/src/lib/routes/secondfactor/u2f/U2FCommon.ts new file mode 100644 index 00000000..7f16c0ee --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.spec.ts b/themes/squares/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.spec.ts new file mode 100644 index 00000000..a54bfbfe --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.ts b/themes/squares/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.ts new file mode 100644 index 00000000..bc4713c7 --- /dev/null +++ b/themes/squares/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<Identity> { + 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<Identity> { + 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/squares/server/src/lib/routes/secondfactor/u2f/register/post.spec.ts b/themes/squares/server/src/lib/routes/secondfactor/u2f/register/post.spec.ts new file mode 100644 index 00000000..de3347a2 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/secondfactor/u2f/register/post.ts b/themes/squares/server/src/lib/routes/secondfactor/u2f/register/post.ts new file mode 100644 index 00000000..7296ccbe --- /dev/null +++ b/themes/squares/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<void> { + 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<void> { + 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/squares/server/src/lib/routes/secondfactor/u2f/register_request/get.spec.ts b/themes/squares/server/src/lib/routes/secondfactor/u2f/register_request/get.spec.ts new file mode 100644 index 00000000..a207c910 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/secondfactor/u2f/register_request/get.ts b/themes/squares/server/src/lib/routes/secondfactor/u2f/register_request/get.ts new file mode 100644 index 00000000..f611af93 --- /dev/null +++ b/themes/squares/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<void> { + 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/squares/server/src/lib/routes/secondfactor/u2f/sign/post.spec.ts b/themes/squares/server/src/lib/routes/secondfactor/u2f/sign/post.spec.ts new file mode 100644 index 00000000..9b137e66 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/secondfactor/u2f/sign/post.ts b/themes/squares/server/src/lib/routes/secondfactor/u2f/sign/post.ts new file mode 100644 index 00000000..7ee711c2 --- /dev/null +++ b/themes/squares/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<void> { + 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<U2f.SignatureResult | U2f.Error> { + 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<void> { + 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/squares/server/src/lib/routes/secondfactor/u2f/sign_request/get.spec.ts b/themes/squares/server/src/lib/routes/secondfactor/u2f/sign_request/get.spec.ts new file mode 100644 index 00000000..dd52b27e --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/secondfactor/u2f/sign_request/get.ts b/themes/squares/server/src/lib/routes/secondfactor/u2f/sign_request/get.ts new file mode 100644 index 00000000..9e93dde0 --- /dev/null +++ b/themes/squares/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<void> { + 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<void> { + 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/squares/server/src/lib/routes/verify/access_control.ts b/themes/squares/server/src/lib/routes/verify/access_control.ts new file mode 100644 index 00000000..136239ae --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/verify/get.spec.ts b/themes/squares/server/src/lib/routes/verify/get.spec.ts new file mode 100644 index 00000000..67cf19fb --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/routes/verify/get.ts b/themes/squares/server/src/lib/routes/verify/get.ts new file mode 100644 index 00000000..f7386169 --- /dev/null +++ b/themes/squares/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<Express.Request, string>( + 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<void> { + 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/squares/server/src/lib/routes/verify/get_basic_auth.ts b/themes/squares/server/src/lib/routes/verify/get_basic_auth.ts new file mode 100644 index 00000000..af23c76c --- /dev/null +++ b/themes/squares/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<Express.Request, string>(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/squares/server/src/lib/routes/verify/get_session_cookie.ts b/themes/squares/server/src/lib/routes/verify/get_session_cookie.ts new file mode 100644 index 00000000..07034481 --- /dev/null +++ b/themes/squares/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<void> { + + // 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<Express.Request, string>( + req, "headers.x-original-url"); + const originalUri = + ObjectPath.get<Express.Request, string>(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/squares/server/src/lib/storage/AuthenticationTraceDocument.d.ts b/themes/squares/server/src/lib/storage/AuthenticationTraceDocument.d.ts new file mode 100644 index 00000000..69818c05 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/storage/CollectionFactoryFactory.ts b/themes/squares/server/src/lib/storage/CollectionFactoryFactory.ts new file mode 100644 index 00000000..92b29abf --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/storage/CollectionFactoryStub.spec.ts b/themes/squares/server/src/lib/storage/CollectionFactoryStub.spec.ts new file mode 100644 index 00000000..17f8bb02 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/storage/CollectionStub.spec.ts b/themes/squares/server/src/lib/storage/CollectionStub.spec.ts new file mode 100644 index 00000000..42895d67 --- /dev/null +++ b/themes/squares/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<any> { + return this.findStub(filter, sortKeys, count); + } + + findOne(filter: any): BluebirdPromise<any> { + return this.findOneStub(filter); + } + + update(filter: any, document: any, options: any): BluebirdPromise<any> { + return this.updateStub(filter, document, options); + } + + remove(filter: any): BluebirdPromise<any> { + return this.removeStub(filter); + } + + insert(document: any): BluebirdPromise<any> { + return this.insertStub(document); + } +} diff --git a/themes/squares/server/src/lib/storage/ICollection.d.ts b/themes/squares/server/src/lib/storage/ICollection.d.ts new file mode 100644 index 00000000..caa6c2a8 --- /dev/null +++ b/themes/squares/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<any>; + findOne(query: any): BluebirdPromise<any>; + update(query: any, updateQuery: any, options?: any): BluebirdPromise<any>; + remove(query: any): BluebirdPromise<any>; + insert(document: any): BluebirdPromise<any>; +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/storage/ICollectionFactory.d.ts b/themes/squares/server/src/lib/storage/ICollectionFactory.d.ts new file mode 100644 index 00000000..39eb42c7 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/storage/IUserDataStore.d.ts b/themes/squares/server/src/lib/storage/IUserDataStore.d.ts new file mode 100644 index 00000000..81df482a --- /dev/null +++ b/themes/squares/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<void>; + retrieveU2FRegistration(userId: string, appId: string): BluebirdPromise<U2FRegistrationDocument>; + + saveAuthenticationTrace(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise<void>; + retrieveLatestAuthenticationTraces(userId: string, count: number): BluebirdPromise<AuthenticationTraceDocument[]>; + + produceIdentityValidationToken(userId: string, token: string, challenge: string, maxAge: number): BluebirdPromise<any>; + consumeIdentityValidationToken(token: string, challenge: string): BluebirdPromise<IdentityValidationDocument>; + + saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise<void>; + retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument>; +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/storage/IdentityValidationDocument.d.ts b/themes/squares/server/src/lib/storage/IdentityValidationDocument.d.ts new file mode 100644 index 00000000..e7fd7b3f --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/storage/TOTPSecretDocument.d.ts b/themes/squares/server/src/lib/storage/TOTPSecretDocument.d.ts new file mode 100644 index 00000000..a6c0bf9e --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/storage/U2FRegistrationDocument.d.ts b/themes/squares/server/src/lib/storage/U2FRegistrationDocument.d.ts new file mode 100644 index 00000000..efec6cb1 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/storage/UserDataStore.spec.ts b/themes/squares/server/src/lib/storage/UserDataStore.spec.ts new file mode 100644 index 00000000..66fb8546 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/storage/UserDataStore.ts b/themes/squares/server/src/lib/storage/UserDataStore.ts new file mode 100644 index 00000000..27b0cddb --- /dev/null +++ b/themes/squares/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<void> { + 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<U2FRegistrationDocument> { + const filter: U2FRegistrationKey = { + userId: userId, + appId: appId + }; + return this.u2fSecretCollection.findOne(filter); + } + + saveAuthenticationTrace(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise<void> { + const newDocument: AuthenticationTraceDocument = { + userId: userId, + date: new Date(), + isAuthenticationSuccessful: isAuthenticationSuccessful, + }; + + return this.authenticationTracesCollection.insert(newDocument); + } + + retrieveLatestAuthenticationTraces(userId: string, count: number): BluebirdPromise<AuthenticationTraceDocument[]> { + const q = { + userId: userId + }; + + return this.authenticationTracesCollection.find(q, { date: -1 }, count); + } + + produceIdentityValidationToken(userId: string, token: string, challenge: string, maxAge: number): BluebirdPromise<any> { + 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<IdentityValidationDocument> { + 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<void> { + const doc = { + userId: userId, + secret: secret + }; + + const filter = { + userId: userId + }; + return this.totpSecretCollection.update(filter, doc, { upsert: true }); + } + + retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument> { + const filter = { + userId: userId + }; + return this.totpSecretCollection.findOne(filter); + } +} diff --git a/themes/squares/server/src/lib/storage/UserDataStoreStub.spec.ts b/themes/squares/server/src/lib/storage/UserDataStoreStub.spec.ts new file mode 100644 index 00000000..5ea27a2d --- /dev/null +++ b/themes/squares/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<void> { + return this.saveU2FRegistrationStub(userId, appId, registration); + } + + retrieveU2FRegistration(userId: string, appId: string): BluebirdPromise<U2FRegistrationDocument> { + return this.retrieveU2FRegistrationStub(userId, appId); + } + + saveAuthenticationTrace(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise<void> { + return this.saveAuthenticationTraceStub(userId, isAuthenticationSuccessful); + } + + retrieveLatestAuthenticationTraces(userId: string, count: number): BluebirdPromise<AuthenticationTraceDocument[]> { + return this.retrieveLatestAuthenticationTracesStub(userId, count); + } + + produceIdentityValidationToken(userId: string, token: string, challenge: string, maxAge: number): BluebirdPromise<any> { + return this.produceIdentityValidationTokenStub(userId, token, challenge, maxAge); + } + + consumeIdentityValidationToken(token: string, challenge: string): BluebirdPromise<IdentityValidationDocument> { + return this.consumeIdentityValidationTokenStub(token, challenge); + } + + saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise<void> { + return this.saveTOTPSecretStub(userId, secret); + } + + retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument> { + return this.retrieveTOTPSecretStub(userId); + } +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/storage/mongo/MongoCollection.spec.ts b/themes/squares/server/src/lib/storage/mongo/MongoCollection.spec.ts new file mode 100644 index 00000000..74a773a1 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/storage/mongo/MongoCollection.ts b/themes/squares/server/src/lib/storage/mongo/MongoCollection.ts new file mode 100644 index 00000000..9771389f --- /dev/null +++ b/themes/squares/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<MongoDB.Collection> { + return this.mongoClient.collection(this.collectionName); + } + + find(query: any, sortKeys?: any, count?: number): Bluebird<any> { + return this.collection() + .then((collection) => collection.find(query).sort(sortKeys).limit(count)) + .then((query) => query.toArray()); + } + + findOne(query: any): Bluebird<any> { + return this.collection() + .then((collection) => collection.findOne(query)); + } + + update(query: any, updateQuery: any, options?: any): Bluebird<any> { + return this.collection() + .then((collection) => collection.update(query, updateQuery, options)); + } + + remove(query: any): Bluebird<any> { + return this.collection() + .then((collection) => collection.remove(query)); + } + + insert(document: any): Bluebird<any> { + return this.collection() + .then((collection) => collection.insertOne(document)); + } + + count(query: any): Bluebird<any> { + return this.collection() + .then((collection) => collection.count(query)); + } +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/storage/mongo/MongoCollectionFactory.spec.ts b/themes/squares/server/src/lib/storage/mongo/MongoCollectionFactory.spec.ts new file mode 100644 index 00000000..bd959cac --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/storage/mongo/MongoCollectionFactory.ts b/themes/squares/server/src/lib/storage/mongo/MongoCollectionFactory.ts new file mode 100644 index 00000000..14a8262c --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/storage/nedb/NedbCollection.spec.ts b/themes/squares/server/src/lib/storage/nedb/NedbCollection.spec.ts new file mode 100644 index 00000000..a69962b6 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/storage/nedb/NedbCollection.ts b/themes/squares/server/src/lib/storage/nedb/NedbCollection.ts new file mode 100644 index 00000000..88a93ad0 --- /dev/null +++ b/themes/squares/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<any>; + findOneAsync<T>(query: any): BluebirdPromise<T>; + insertAsync<T>(newDoc: T): BluebirdPromise<any>; + removeAsync(query: any): BluebirdPromise<any>; + countAsync(query: any): BluebirdPromise<number>; + } +} + +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<any> { + const q = this.collection.find(query).sort(sortKeys).limit(count); + return BluebirdPromise.promisify(q.exec, { context: q })(); + } + + findOne(query: any): BluebirdPromise<any> { + return this.collection.findOneAsync(query); + } + + update(query: any, updateQuery: any, options?: any): BluebirdPromise<any> { + return this.collection.updateAsync(query, updateQuery, options); + } + + remove(query: any): BluebirdPromise<any> { + return this.collection.removeAsync(query); + } + + insert(document: any): BluebirdPromise<any> { + return this.collection.insertAsync(document); + } + + count(query: any): BluebirdPromise<number> { + return this.collection.countAsync(query); + } +} \ No newline at end of file diff --git a/themes/squares/server/src/lib/storage/nedb/NedbCollectionFactory.spec.ts b/themes/squares/server/src/lib/storage/nedb/NedbCollectionFactory.spec.ts new file mode 100644 index 00000000..da90c661 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/storage/nedb/NedbCollectionFactory.ts b/themes/squares/server/src/lib/storage/nedb/NedbCollectionFactory.ts new file mode 100644 index 00000000..49c4dc85 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/stubs/express.spec.ts b/themes/squares/server/src/lib/stubs/express.spec.ts new file mode 100644 index 00000000..48f15d7e --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/stubs/ldapjs.spec.ts b/themes/squares/server/src/lib/stubs/ldapjs.spec.ts new file mode 100644 index 00000000..045c0e11 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/stubs/speakeasy.spec.ts b/themes/squares/server/src/lib/stubs/speakeasy.spec.ts new file mode 100644 index 00000000..023614dc --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/stubs/u2f.spec.ts b/themes/squares/server/src/lib/stubs/u2f.spec.ts new file mode 100644 index 00000000..234b28c1 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/utils/HashGenerator.spec.ts b/themes/squares/server/src/lib/utils/HashGenerator.spec.ts new file mode 100644 index 00000000..f19619a6 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/utils/HashGenerator.ts b/themes/squares/server/src/lib/utils/HashGenerator.ts new file mode 100644 index 00000000..e67de32b --- /dev/null +++ b/themes/squares/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<string> { + const saltSize = 16; + // $6 means SHA512 + const _salt = Util.format("$6$rounds=%d$%s", rounds, + (salt) ? salt : RandomString.generate(16)); + + const cryptAsync = BluebirdPromise.promisify<string, string, string>(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/squares/server/src/lib/utils/ObjectCloner.ts b/themes/squares/server/src/lib/utils/ObjectCloner.ts new file mode 100644 index 00000000..3e125d74 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/utils/SafeRedirection.spec.ts b/themes/squares/server/src/lib/utils/SafeRedirection.spec.ts new file mode 100644 index 00000000..4126949f --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/utils/SafeRedirection.ts b/themes/squares/server/src/lib/utils/SafeRedirection.ts new file mode 100644 index 00000000..9e6a32e0 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/utils/URLDecomposer.spec.ts b/themes/squares/server/src/lib/utils/URLDecomposer.spec.ts new file mode 100644 index 00000000..cbb03873 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/utils/URLDecomposer.ts b/themes/squares/server/src/lib/utils/URLDecomposer.ts new file mode 100644 index 00000000..9bdf2e9d --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/web_server/Configurator.ts b/themes/squares/server/src/lib/web_server/Configurator.ts new file mode 100644 index 00000000..6e404874 --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/web_server/RestApi.ts b/themes/squares/server/src/lib/web_server/RestApi.ts new file mode 100644 index 00000000..9144a15b --- /dev/null +++ b/themes/squares/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/squares/server/src/lib/web_server/middlewares/RequireValidatedFirstFactor.ts b/themes/squares/server/src/lib/web_server/middlewares/RequireValidatedFirstFactor.ts new file mode 100644 index 00000000..ecfd7576 --- /dev/null +++ b/themes/squares/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<void> { + + return new BluebirdPromise<void>(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/squares/server/src/lib/web_server/middlewares/WithHeadersLogged.ts b/themes/squares/server/src/lib/web_server/middlewares/WithHeadersLogged.ts new file mode 100644 index 00000000..139db114 --- /dev/null +++ b/themes/squares/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/squares/server/src/resources/email-template.ejs b/themes/squares/server/src/resources/email-template.ejs new file mode 100644 index 00000000..f59c2f94 --- /dev/null +++ b/themes/squares/server/src/resources/email-template.ejs @@ -0,0 +1,254 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> + <title>Simples-Minimalistic Responsive Template</title> + + <style type="text/css"> + /* Client-specific Styles */ + #outlook a {padding:0;} /* Force Outlook to provide a "view in browser" menu link. */ + body{background: rgb(0, 0, 0);width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0;} + /* Prevent Webkit and Windows Mobile platforms from changing default font sizes, while not breaking desktop design. */ + .ExternalClass {width:100%;} /* Force Hotmail to display emails at full width */ + .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;} /* Force Hotmail to display normal line spacing.*/ + #backgroundTable {margin:0; padding:0; width:100% !important; line-height: 100% !important;} + img {outline:none; text-decoration:none;border:none; -ms-interpolation-mode: bicubic;} + a img {border:none;} + .image_fix {display:block;} + p {margin: 0px 0px !important;} + table td {border-collapse: collapse;} + table { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; } + a {color: #ffffff;text-decoration: none;text-decoration:none!important;} + h1 {color: #ffffff; line-height: 30px; } + .button {padding: 15px 30px; border-radius: 10px; background: rgb(3, 183, 3); text-decoration:none; } + + /*STYLES*/ + table[class=full] { width: 100%; clear: both; } + /*IPAD STYLES*/ + @media only screen and (max-width: 640px) { + a[href^="tel"], a[href^="sms"] { + text-decoration: none; + color: #ffffff; /* or whatever your want */ + pointer-events: none; + cursor: default; + } + .mobile_link a[href^="tel"], .mobile_link a[href^="sms"] { + text-decoration: default; + color: #000000 !important; + pointer-events: auto; + cursor: default; + } + table[class=devicewidth] {width: 440px!important;text-align:center!important;} + table[class=devicewidthinner] {width: 420px!important;text-align:center!important;} + img[class=banner] {width: 440px!important;height:220px!important;} + img[class=colimg2] {width: 440px!important;height:220px!important;} + + } + /*IPHONE STYLES*/ + @media only screen and (max-width: 480px) { + a[href^="tel"], a[href^="sms"] { + text-decoration: none; + color: #000000; /* or whatever your want */ + pointer-events: none; + cursor: default; + } + .mobile_link a[href^="tel"], .mobile_link a[href^="sms"] { + text-decoration: default; + color: #000000 !important; + pointer-events: auto; + cursor: default; + } + table[class=devicewidth] {width: 280px!important;text-align:center!important;} + table[class=devicewidthinner] {width: 260px!important;text-align:center!important;} + img[class=banner] {width: 280px!important;height:140px!important;} + img[class=colimg2] {width: 280px!important;height:140px!important;} + td[class=mobile-hide]{display:none!important;} + td[class="padding-bottom25"]{padding-bottom:25px!important;} + + } + </style> + </head> + <body> +<!-- Start of header --> +<table width="100%" bgcolor="#000000" cellpadding="0" cellspacing="0" border="0" id="backgroundTable" st-sortable="header"> + <tbody> + <tr> + <td> + <table width="600" cellpadding="0" cellspacing="0" border="0" align="center" class="devicewidth"> + <tbody> + <tr> + <td width="100%"> + <table width="600" cellpadding="0" cellspacing="0" border="0" align="center" class="devicewidth"> + <tbody> + <!-- Spacing --> + <tr> + <td height="20" style="font-size:1px; line-height:1px; mso-line-height-rule: exactly;"> </td> + </tr> + <!-- Spacing --> + <tr> + <td> + <!-- logo --> + <table width="140" align="center" border="0" cellpadding="0" cellspacing="0" class="devicewidth"> + <tbody> + <tr> + <td width="300" height="50" align="center"> + <h1><%= title %></h1> + </td> + </tr> + </tbody> + </table> + <!-- end of logo --> + </td> + </tr> + <!-- Spacing --> + <tr> + <td height="20" style="font-size:1px; line-height:1px; mso-line-height-rule: exactly;"> </td> + </tr> + <!-- Spacing --> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> +</table> +<!-- End of Header --> +<!-- Start of seperator --> +<table width="100%" bgcolor="#000000" cellpadding="0" cellspacing="0" border="0" id="backgroundTable" st-sortable="seperator"> + <tbody> + <tr> + <td> + <table width="600" align="center" cellspacing="0" cellpadding="0" border="0" class="devicewidth"> + <tbody> + <tr> + <td align="center" height="20" style="font-size:1px; line-height:1px;"> </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> +</table> +<!-- End of seperator --> +<!-- Start Full Text --> +<table width="100%" bgcolor="#000000" cellpadding="0" cellspacing="0" border="0" id="backgroundTable" st-sortable="full-text"> + <tbody> + <tr> + <td> + <table width="600" cellpadding="0" cellspacing="0" border="0" align="center" class="devicewidth"> + <tbody> + <tr> + <td width="100%"> + <table width="600" cellpadding="0" cellspacing="0" border="0" align="center" class="devicewidth"> + <tbody> + <!-- Spacing --> + <tr> + <td height="20" style="font-size:1px; line-height:1px; mso-line-height-rule: exactly;"> </td> + </tr> + <!-- Spacing --> + <tr> + <td> + <table width="560" align="center" cellpadding="0" cellspacing="0" border="0" class="devicewidthinner"> + <tbody> + <!-- Title --> + <tr> + <td style="font-family: Helvetica, arial, sans-serif; font-size: 16px; color: #ffffff; text-align:center; line-height: 30px;" st-title="fulltext-content"> + 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. + </td> + </tr> + <!-- End of Title --> + <!-- spacing --> + <tr> + <td width="100%" height="20" style="font-size:1px; line-height:1px; mso-line-height-rule: exactly;"> </td> + </tr> + <!-- End of spacing --> + <!-- content --> + <tr> + <td style="font-family: Helvetica, arial, sans-serif; font-size: 16px; color: #666666; text-align:center; line-height: 30px;" st-content="fulltext-content"> + <a href="<%= url %>" class="button"><%= button_title %></a> + </td> + </tr> + <!-- End of content --> + </tbody> + </table> + </td> + </tr> + <!-- Spacing --> + <tr> + <td height="20" style="font-size:1px; line-height:1px; mso-line-height-rule: exactly;"> </td> + </tr> + <!-- Spacing --> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> +</table> +<!-- end of full text --> +<!-- Start of seperator --> +<table width="100%" bgcolor="#000000" cellpadding="0" cellspacing="0" border="0" id="backgroundTable" st-sortable="seperator"> + <tbody> + <tr> + <td> + <table width="600" align="center" cellspacing="0" cellpadding="0" border="0" class="devicewidth"> + <tbody> + <tr> + <td align="center" height="30" style="font-size:1px; line-height:1px;"> </td> + </tr> + <tr> + <td width="550" align="center" height="1" bgcolor="#d1d1d1" style="font-size:1px; line-height:1px;"> </td> + </tr> + <tr> + <td align="center" height="30" style="font-size:1px; line-height:1px;"> </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> +</table> +<!-- End of seperator --> +<!-- Start of Postfooter --> +<table width="100%" bgcolor="#000000" cellpadding="0" cellspacing="0" border="0" id="backgroundTable" st-sortable="postfooter" > + <tbody> + <tr> + <td> + <table width="600" cellpadding="0" cellspacing="0" border="0" align="center" class="devicewidth"> + <tbody> + <tr> + <td width="100%"> + <table width="600" cellpadding="0" cellspacing="0" border="0" align="center" class="devicewidth"> + <tbody> + <tr> + <td align="center" valign="middle" style="font-family: Helvetica, arial, sans-serif; font-size: 14px;color: #ffffff" st-content="postfooter"> + Please ignore this email if you did not initiate the process. + </td> + </tr> + <!-- Spacing --> + <tr> + <td width="100%" height="20"></td> + </tr> + <!-- Spacing --> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> +</table> +<!-- End of postfooter --> + + </body> + </html> + diff --git a/themes/squares/server/src/views/already-logged-in.pug b/themes/squares/server/src/views/already-logged-in.pug new file mode 100644 index 00000000..137bbea3 --- /dev/null +++ b/themes/squares/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 <b>#{ username }</b>.<br/><br/> + | If you are not redirected in few seconds, click <a href="#{ redirection_url }">here</a>.<br/><br/> + | Otherwise, click <a href="#{ logout_endpoint }">here</a> to log off. + else + p You are already logged in as <b>#{ username }</b>.<br/><br/> + | Click <a href="#{ logout_endpoint }">here</a> to log off. diff --git a/themes/squares/server/src/views/errors/.directory b/themes/squares/server/src/views/errors/.directory new file mode 100644 index 00000000..33f71bea --- /dev/null +++ b/themes/squares/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/squares/server/src/views/errors/401.pug b/themes/squares/server/src/views/errors/401.pug new file mode 100644 index 00000000..b7a222ad --- /dev/null +++ b/themes/squares/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.<br/><br/> + | Please click <a href=#{redirection_url}>here</a> 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/squares/server/src/views/errors/403.pug b/themes/squares/server/src/views/errors/403.pug new file mode 100644 index 00000000..f4b5ca8a --- /dev/null +++ b/themes/squares/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.<br/><br/> + | Please click <a href=#{redirection_url}>here</a> if you are not + | redirected in few seconds. + else + p You don't have enough privileges to access this resource. diff --git a/themes/squares/server/src/views/errors/404.pug b/themes/squares/server/src/views/errors/404.pug new file mode 100644 index 00000000..06d6375f --- /dev/null +++ b/themes/squares/server/src/views/errors/404.pug @@ -0,0 +1,11 @@ +extends ../layout/layout.pug + +block variables + - page_classname = "error-404"; + +block form-header + <h1>Error 404</h1> + +block content + img(class="header-img" src="/img/warning.png" alt="warning") + p Page not found. diff --git a/themes/squares/server/src/views/firstfactor.pug b/themes/squares/server/src/views/firstfactor.pug new file mode 100644 index 00000000..57447071 --- /dev/null +++ b/themes/squares/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/squares/server/src/views/layout/layout.pug b/themes/squares/server/src/views/layout/layout.pug new file mode 100644 index 00000000..43247436 --- /dev/null +++ b/themes/squares/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 <a class="authelia-brand" href="https://github.com/clems4ever/authelia">Authelia</a> + block entrypoint + script(src="/js/authelia.js", type="text/javascript") diff --git a/themes/squares/server/src/views/need-identity-validation.pug b/themes/squares/server/src/views/need-identity-validation.pug new file mode 100644 index 00000000..4cfd6271 --- /dev/null +++ b/themes/squares/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/squares/server/src/views/password-reset-form.pug b/themes/squares/server/src/views/password-reset-form.pug new file mode 100644 index 00000000..fd931189 --- /dev/null +++ b/themes/squares/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/squares/server/src/views/password-reset-request.pug b/themes/squares/server/src/views/password-reset-request.pug new file mode 100644 index 00000000..855b5998 --- /dev/null +++ b/themes/squares/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/squares/server/src/views/secondfactor.pug b/themes/squares/server/src/views/secondfactor.pug new file mode 100644 index 00000000..87b57818 --- /dev/null +++ b/themes/squares/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 <b>#{username}</b> + div(class="row") + div(class="u2f-token") + img(src="/img/pendrive.png", alt="security key") + p + | Please, touch your <a href="https://www.yubico.com/products/yubikey-hardware/fido-u2f-security-key/">security key</a><br/> + b Or<br/> + | 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/squares/server/src/views/totp-register.pug b/themes/squares/server/src/views/totp-register.pug new file mode 100644 index 00000000..1b4d9835 --- /dev/null +++ b/themes/squares/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/squares/server/src/views/u2f-register.pug b/themes/squares/server/src/views/u2f-register.pug new file mode 100644 index 00000000..d52eba6c --- /dev/null +++ b/themes/squares/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/squares/server/test/requests.ts b/themes/squares/server/test/requests.ts new file mode 100644 index 00000000..93fa0de4 --- /dev/null +++ b/themes/squares/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<TRequest extends Request, + TOptions extends CoreOptions, + TUriUrlOptions> { + getAsync(uri: string, options?: RequiredUriUrl): BluebirdPromise<RequestResponse>; + getAsync(uri: string): BluebirdPromise<RequestResponse>; + getAsync(options: RequiredUriUrl & CoreOptions): BluebirdPromise<RequestResponse>; + + postAsync(uri: string, options?: CoreOptions): BluebirdPromise<RequestResponse>; + postAsync(uri: string): BluebirdPromise<RequestResponse>; + postAsync(options: RequiredUriUrl & CoreOptions): BluebirdPromise<RequestResponse>; + } +} + +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/squares/server/tsconfig.json b/themes/squares/server/tsconfig.json new file mode 100644 index 00000000..ebe98c5e --- /dev/null +++ b/themes/squares/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/squares/server/tslint.json b/themes/squares/server/tslint.json new file mode 100644 index 00000000..c2c1b750 --- /dev/null +++ b/themes/squares/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/squares/server/types/.directory b/themes/squares/server/types/.directory new file mode 100644 index 00000000..1e65000e --- /dev/null +++ b/themes/squares/server/types/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,17,20,58,27 +Version=3 +ViewMode=1 diff --git a/themes/squares/server/types/AuthenticationSession.ts b/themes/squares/server/types/AuthenticationSession.ts new file mode 100644 index 00000000..bbed0e71 --- /dev/null +++ b/themes/squares/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/squares/server/types/Dependencies.ts b/themes/squares/server/types/Dependencies.ts new file mode 100644 index 00000000..f20404db --- /dev/null +++ b/themes/squares/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/squares/server/types/Identity.ts b/themes/squares/server/types/Identity.ts new file mode 100644 index 00000000..e985984e --- /dev/null +++ b/themes/squares/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/squares/server/types/TOTPSecret.ts b/themes/squares/server/types/TOTPSecret.ts new file mode 100644 index 00000000..d6775f2f --- /dev/null +++ b/themes/squares/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/squares/server/types/U2FRegistration.ts b/themes/squares/server/types/U2FRegistration.ts new file mode 100644 index 00000000..b6080af0 --- /dev/null +++ b/themes/squares/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/squares/server/types/dovehash.d.ts b/themes/squares/server/types/dovehash.d.ts new file mode 100644 index 00000000..c354609c --- /dev/null +++ b/themes/squares/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/squares/server/types/speakeasy.d.ts b/themes/squares/server/types/speakeasy.d.ts new file mode 100644 index 00000000..6ea06948 --- /dev/null +++ b/themes/squares/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/triangles/client/src/.directory b/themes/triangles/client/src/.directory new file mode 100644 index 00000000..72a940d6 --- /dev/null +++ b/themes/triangles/client/src/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,18,8,25,40 +Version=3 +ViewMode=1 diff --git a/themes/triangles/client/src/css/.directory b/themes/triangles/client/src/css/.directory new file mode 100644 index 00000000..6e4b3f63 --- /dev/null +++ b/themes/triangles/client/src/css/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,17,20,56,41 +Version=3 +ViewMode=1 diff --git a/themes/triangles/client/src/css/00-bootstrap.min.css b/themes/triangles/client/src/css/00-bootstrap.min.css new file mode 100644 index 00000000..dfeacbb8 --- /dev/null +++ b/themes/triangles/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/triangles/client/src/css/01-main.css b/themes/triangles/client/src/css/01-main.css new file mode 100644 index 00000000..347c0b81 --- /dev/null +++ b/themes/triangles/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/triangles/client/src/css/02-login.css b/themes/triangles/client/src/css/02-login.css new file mode 100644 index 00000000..a6984267 --- /dev/null +++ b/themes/triangles/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/triangles/client/src/css/03-errors.css b/themes/triangles/client/src/css/03-errors.css new file mode 100644 index 00000000..e9f97f33 --- /dev/null +++ b/themes/triangles/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/triangles/client/src/css/03-password-reset-form.css b/themes/triangles/client/src/css/03-password-reset-form.css new file mode 100644 index 00000000..34066bc2 --- /dev/null +++ b/themes/triangles/client/src/css/03-password-reset-form.css @@ -0,0 +1,4 @@ + +.password-reset-form .header-img { + border-radius: 0%; +} diff --git a/themes/triangles/client/src/css/03-password-reset-request.css b/themes/triangles/client/src/css/03-password-reset-request.css new file mode 100644 index 00000000..1a2ad4df --- /dev/null +++ b/themes/triangles/client/src/css/03-password-reset-request.css @@ -0,0 +1,4 @@ + +.password-reset-request .header-img { + border-radius: 0%; +} diff --git a/themes/triangles/client/src/css/03-totp-register.css b/themes/triangles/client/src/css/03-totp-register.css new file mode 100644 index 00000000..cb76720a --- /dev/null +++ b/themes/triangles/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/triangles/client/src/css/03-u2f-register.css b/themes/triangles/client/src/css/03-u2f-register.css new file mode 100644 index 00000000..e54cddf8 --- /dev/null +++ b/themes/triangles/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/triangles/client/src/img/LargeTriangles.svg b/themes/triangles/client/src/img/LargeTriangles.svg new file mode 100644 index 00000000..0988bcb3 --- /dev/null +++ b/themes/triangles/client/src/img/LargeTriangles.svg @@ -0,0 +1 @@ +<svg xmlns='http://www.w3.org/2000/svg' width='300' height='250' viewBox='0 0 1080 900'><rect fill='#000000' width='1080' height='900'/><g fill-opacity='0.16'><polygon fill='#444' points='90 150 0 300 180 300'/><polygon points='90 150 180 0 0 0'/><polygon fill='#AAA' points='270 150 360 0 180 0'/><polygon fill='#DDD' points='450 150 360 300 540 300'/><polygon fill='#999' points='450 150 540 0 360 0'/><polygon points='630 150 540 300 720 300'/><polygon fill='#DDD' points='630 150 720 0 540 0'/><polygon fill='#444' points='810 150 720 300 900 300'/><polygon fill='#FFF' points='810 150 900 0 720 0'/><polygon fill='#DDD' points='990 150 900 300 1080 300'/><polygon fill='#444' points='990 150 1080 0 900 0'/><polygon fill='#DDD' points='90 450 0 600 180 600'/><polygon points='90 450 180 300 0 300'/><polygon fill='#666' points='270 450 180 600 360 600'/><polygon fill='#AAA' points='270 450 360 300 180 300'/><polygon fill='#DDD' points='450 450 360 600 540 600'/><polygon fill='#999' points='450 450 540 300 360 300'/><polygon fill='#999' points='630 450 540 600 720 600'/><polygon fill='#FFF' points='630 450 720 300 540 300'/><polygon points='810 450 720 600 900 600'/><polygon fill='#DDD' points='810 450 900 300 720 300'/><polygon fill='#AAA' points='990 450 900 600 1080 600'/><polygon fill='#444' points='990 450 1080 300 900 300'/><polygon fill='#222' points='90 750 0 900 180 900'/><polygon points='270 750 180 900 360 900'/><polygon fill='#DDD' points='270 750 360 600 180 600'/><polygon points='450 750 540 600 360 600'/><polygon points='630 750 540 900 720 900'/><polygon fill='#444' points='630 750 720 600 540 600'/><polygon fill='#AAA' points='810 750 720 900 900 900'/><polygon fill='#666' points='810 750 900 600 720 600'/><polygon fill='#999' points='990 750 900 900 1080 900'/><polygon fill='#999' points='180 0 90 150 270 150'/><polygon fill='#444' points='360 0 270 150 450 150'/><polygon fill='#FFF' points='540 0 450 150 630 150'/><polygon points='900 0 810 150 990 150'/><polygon fill='#222' points='0 300 -90 450 90 450'/><polygon fill='#FFF' points='0 300 90 150 -90 150'/><polygon fill='#FFF' points='180 300 90 450 270 450'/><polygon fill='#666' points='180 300 270 150 90 150'/><polygon fill='#222' points='360 300 270 450 450 450'/><polygon fill='#FFF' points='360 300 450 150 270 150'/><polygon fill='#444' points='540 300 450 450 630 450'/><polygon fill='#222' points='540 300 630 150 450 150'/><polygon fill='#AAA' points='720 300 630 450 810 450'/><polygon fill='#666' points='720 300 810 150 630 150'/><polygon fill='#FFF' points='900 300 810 450 990 450'/><polygon fill='#999' points='900 300 990 150 810 150'/><polygon points='0 600 -90 750 90 750'/><polygon fill='#666' points='0 600 90 450 -90 450'/><polygon fill='#AAA' points='180 600 90 750 270 750'/><polygon fill='#444' points='180 600 270 450 90 450'/><polygon fill='#444' points='360 600 270 750 450 750'/><polygon fill='#999' points='360 600 450 450 270 450'/><polygon fill='#666' points='540 600 630 450 450 450'/><polygon fill='#222' points='720 600 630 750 810 750'/><polygon fill='#FFF' points='900 600 810 750 990 750'/><polygon fill='#222' points='900 600 990 450 810 450'/><polygon fill='#DDD' points='0 900 90 750 -90 750'/><polygon fill='#444' points='180 900 270 750 90 750'/><polygon fill='#FFF' points='360 900 450 750 270 750'/><polygon fill='#AAA' points='540 900 630 750 450 750'/><polygon fill='#FFF' points='720 900 810 750 630 750'/><polygon fill='#222' points='900 900 990 750 810 750'/><polygon fill='#222' points='1080 300 990 450 1170 450'/><polygon fill='#FFF' points='1080 300 1170 150 990 150'/><polygon points='1080 600 990 750 1170 750'/><polygon fill='#666' points='1080 600 1170 450 990 450'/><polygon fill='#DDD' points='1080 900 1170 750 990 750'/></g></svg> \ No newline at end of file diff --git a/themes/triangles/client/src/img/background.jpg b/themes/triangles/client/src/img/background.jpg new file mode 100644 index 0000000000000000000000000000000000000000..974ea273fa87adccec4b4433fc4d097c04ae4c31 GIT binary patch literal 587 zcmb7<L2d#u3`M__34}>za3BG;m3jxdn?<FO;0Ua`=skLeO1)6UA)wuGli(4e5-l+5 zk2kTNzrXV?AHeH&)q;qyk%#<^XK1Cm5*1R$8dDUe91aK8m)^V5xvHLURfpO+o^hDQ zl#(CM7qhsS#1uOd(lS$+kujrKxhno!`4hq76;GN1R3IHFZ;>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/triangles/client/src/img/icon.png b/themes/triangles/client/src/img/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..040d10c1ab5feaf6720fa8e8c0a0aa336402894e GIT binary patch literal 1461 zcmV;m1xosfP)<h;3K|Lk000e1NJLTq001BW001Be1^@s6b9#F800006VoOIv0000h z08pkdLy7<Z010qNS#tmYE+YT{E+YYWr9XB6000McNliru;s_oG8!(+tmiGVv02y>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&;<c#bG`q|<OHK2`(Z1F*_Z`5B=eE$u=<U^0j`K;>aqBgCCWIgr3<ka|x8+B} z=a2UPs{j_R*hn!>%#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*TPM9r<qXcd6m05r&RoHm4m;gEgAmh$xI#g^d-02A2<SOZKs0Fakk zCV;f}N;04Ylv2Ko8hJ~C0f4tf`c7QSTS2Kbrw{nRCq)1PUM6U!-pFG)X}mxQr@rxh z%NZxx&ItDaAiIbP3TFUA=k9FE-|@ywV|M{5Mwq>NfXHy+Yt0<CGX!NMV4F_bX_3Cu z6leytj)3)gF#nvPg|ic(!y#Mc1rUoTx(oSxaOq1;waYiG^KtBDU<#}^ps%F`%pZG! z@ZDuK7P-_uke7mR$W{}vgl0hG;;m*$)0<=C;|CNG#6!=2pfhQn0(c{?w0B`))3ady z%0Cl4FjCNznvH_Um5u=-jHPHOD-ABr*1N8|IHB3gHn0o87637TH>3Y#2NpCw4d56E zr8CNk8ARxwwFZ)MCdHT&Pdu5eC<zAkNFjQ&&OQagySVy88|F8z0_Zr1DnP>DZpn@^ zg!ri-09%OTsSF5HzMiccym0J>)_N^~%UQkOi}{T!LDU98Rq07el%z1z(&3<exFCS? zva(J?c=IzJPpo=))%qG=(T|)R0OJKP_a}$WG7#DzXpR@T$gvX`0V#(p+v+a}psJ?) z&!BCd02o@sm>7?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<Qro>-`$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/triangles/client/src/img/mail.png b/themes/triangles/client/src/img/mail.png new file mode 100644 index 0000000000000000000000000000000000000000..834bfce9107a94be10da4c011fb78e37a192888f GIT binary patch literal 3545 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 Jtx8RN)PI?rK$`#n literal 0 HcmV?d00001 diff --git a/themes/triangles/client/src/img/matrix_circle_128x128.png b/themes/triangles/client/src/img/matrix_circle_128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..856e01556b91e0ac25959bd41e9af51888d9ac56 GIT binary patch literal 35750 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 literal 0 HcmV?d00001 diff --git a/themes/triangles/client/src/img/notifications/.directory b/themes/triangles/client/src/img/notifications/.directory new file mode 100644 index 00000000..7c8b8054 --- /dev/null +++ b/themes/triangles/client/src/img/notifications/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,17,20,57,35 +Version=3 +ViewMode=1 diff --git a/themes/triangles/client/src/img/notifications/error.png b/themes/triangles/client/src/img/notifications/error.png new file mode 100644 index 0000000000000000000000000000000000000000..bf64d28f7519c816cf348e8a1d39ef0a5d588cf1 GIT binary patch literal 863 zcmV-l1EBngP)<h;3K|Lk000e1NJLTq001BW001Be0{{R3M5Kzw00003b3#c}2nYz< z;ZNWI000SaNLh0L007_s007_tqF?^X0000PbVXQnQ*UN;cVTj60C#tHE@^ISb7Ns} zWiD@WXPfRk8UO$RHBd}cMgRZ*|4B*zU_kU$O7&7l;666mJT>%BO3pDW&@U?SP)W=! zDc?Ui-aR+*QAzPrN%B%k(lRg6F)Yt8EY2<}@KQ<fQc1=qBgQBs@lr|hQc2i4GsGn# z(K0Q+As)RR9Pv^~@lr|gQc1QK6}A@^x*Hk08yT_^60;K$tqu;Y4-c&n5Uvjot`HEf z6BDo(7qJ~3vKt%6ZEeSHZqqa`(|&%`r>E7hu-4Yr*R{3Rwzk*2yw|<G*VotBy}j9x zklEPS+LM#o*4EoRHrqWn+m@Ew*4EsZnBSwL;Av&xq@>}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<L2q=>+SOM_51t${QmxXRol}500C!7L_t(I%XO1yLxV67h6AluQ2_@iwQ8v|?ok!@ z-YYn2>#nW&|35*<zyR_6!g23IayfDUL{c=<a$MK3Oicl4WsM?=R5Y>)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<bW zoxxc`n84x{*F^tn4pPDb4w4X;flmnsxF}7W6Q2;S47l5!ft+w9Tiklyc0>%Oiwu0v z+v3DX>X<hk(K-YAl8rjqq`=N11-cTgK7f!ChttF#SK>cwFHCNP3#`xu`i0+p@$j}v zvupdO%QTv77cWeCo)rccc+eBSa%GrMK}}rR1Tof<y7n<*v_mpD0#Rs{jvgnbx?`g! p3@;%j2HGU^&BlLdxVKXn|6j&?*~IE2*wO$1002ovPDHLkV1fhnr_}%e literal 0 HcmV?d00001 diff --git a/themes/triangles/client/src/img/notifications/info.png b/themes/triangles/client/src/img/notifications/info.png new file mode 100644 index 0000000000000000000000000000000000000000..67928e88c95fca77407947ff12e43039dd0b3067 GIT binary patch literal 732 zcmV<20wev2P)<h;3K|Lk000e1NJLTq001BW001Be0{{R3M5Kzw00003b3#c}2nYz< z;ZNWI000SaNLh0L0089x0089ykL8;@0000PbVXQnQ*UN;cVTj60C#tHE@^ISb7Ns} zWiD@WXPfRk8UO$Q_E1bzMgRZ*B(~cizuzFW)hW2vD7Dfiw$dfI(<QdkCAZThxYH%L z(j~UjCAZThx6>uJ(<QglCAZTix6>!M(<r#qEV<Mzy45ba)iAr&GP~9`z1BLu*E_z~ zJipjKzt}{<*+;_KNW<Do!`oBF+*QZjS;*d7$=+Sb-d)PxW6R%T%;06r;Azd_Z_na% z(c^m4<bl=ZgVyGX*yoJd=#ks$mE7u>-Rha$>YCo`p5N@L;_j;B?zHCcxaje_>G8bj z^1kZw$L#db@AlE~_S5k8)$#Y&^7q~J_~G^W<o5dT`uy|z{q_9)`u+a={{H>`|Nj5~ z|Ns9nkp&R|000qmQchC<2M8D$ElGrzn8C=}<MQ+M)u=IB0003yNkl<ZILn1oS6jkR z3{6YvqJseLjT2FEBjO$?PR{@TGd>`_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|* O0000<MNUMnLSTYudwt~q literal 0 HcmV?d00001 diff --git a/themes/triangles/client/src/img/notifications/success.png b/themes/triangles/client/src/img/notifications/success.png new file mode 100644 index 0000000000000000000000000000000000000000..d3998392dec4edddb1b62c2c9b81f6bc8c3e4132 GIT binary patch literal 931 zcmV;U16=%xP)<h;3K|Lk000e1NJLTq001BW001Be0{{R3M5Kzw00003b3#c}2nYz< z;ZNWI000SaNLh0L007_s007_tqF?^X0000PbVXQnQ*UN;cVTj60C#tHE@^ISb7Ns} zWiD@WXPfRk8UO$RZct2AMgRZ*B(`lpzkoT$aVN2M52a}}zkoBidMmScD7SbXvUfAP zeJHbeF}ZpjtaKc#b27PnGrD^+x_mFVdmpWJ7^!m}t#uu(bTYbp8?1CPx_mOad@i?o zGrD{ks&f;jaWcDn5v6c4x_mOad=sW|6Q*$yq;L?Va1o?%4We%jqi+wSZwjDp5~Xny zrg0Ugau=y{8mn|As(vf5fH1jxF}Zv)u!b_ad^50yG_Z#>yM8sXh&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|E<o537_wVZW^6U5W?D+KT`Sb1g^zHfc?fLZX`1S7j^zZuh@cQ-f`u6tx z`S$$!`273){{8*_{r&#_|NsAug0YnV001m>QchC<2M7rb5f~XBAS*37Npg6EmzbTd zzP`c1#K_v+<K*x1^Yiug`1<|*{{H@<nC93300D1FL_t(I%XO3YTf#69h65^~SZ(X7 zMHIn>#*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<idKQDQhYgPA?FW$`4WYNdul z8vZ-^po{c4<1kKtF?E4OrAI9eo7f>|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|38<V#k&v!+G79!002ovPDHLk FV1l=5+6w>x literal 0 HcmV?d00001 diff --git a/themes/triangles/client/src/img/notifications/warning.png b/themes/triangles/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{<PM>%Ew#`y9xfr~3@Z1+ro2RJG>m8qL$S znrCV?&(>?6YtTC1sCB+c>jDrpYh7&Cy4<F9wL|M_r}ni@?dx6IH@dZN^l0De)4tuW zeP^Q1-AOw4rs&+Cs`GG~&co?Ck7npRo~iR>w(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~n<p-i9nXY!tU#&M|=du+)*luVzFLC=Q_hRb8g<&7FAGk?I z2^X;2%wU-Nh_k{>vt(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/triangles/client/src/img/padlock.png b/themes/triangles/client/src/img/padlock.png new file mode 100644 index 0000000000000000000000000000000000000000..31abbaeefcbc507ac59450a76b222d0f4e2ef298 GIT binary patch literal 3265 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 literal 0 HcmV?d00001 diff --git a/themes/triangles/client/src/img/password_white.png b/themes/triangles/client/src/img/password_white.png new file mode 100644 index 0000000000000000000000000000000000000000..0b93ef3fb7535313d9378066d0485f69e6e627df GIT binary patch literal 3858 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 literal 0 HcmV?d00001 diff --git a/themes/triangles/client/src/img/pendrive.png b/themes/triangles/client/src/img/pendrive.png new file mode 100644 index 0000000000000000000000000000000000000000..fa49178c326631167ec642aaacc117c38b31f6bf GIT binary patch literal 6721 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 Hcm4kWRQYbi literal 0 HcmV?d00001 diff --git a/themes/triangles/client/src/img/sharingan.png b/themes/triangles/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^Y<S5D0_;m6z54TEG7;lsCX{ujr>yph0zz*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%e<Q*i%aoIfm%H2MB<AP~YpVVZ+-{!?>S z<iv1q{OMq1ny+v6&u;c(ngc-A{sb@~KUdhp&+f{k{adIwxh}R5@;5kk_-arWXO^## z2N#+jh#O%av=3)R-F<4B@;8YLwN=iYz#aDvuLdt{d`sl1x0d)0{+t|J);=is5(@eb z%Z3g66-!QTQeonq`)o4pHh=CS6U<e`<wsdXWMzJ(V`4{lMX`pjg0GS|TaRp0?W8tc zwEa1K_;Zo0#>Vv{j~=TYhNpxyHX;~Go1uiO1U_fnzT4&qDZmk~CGK*MN&_cf3NiT0 z`FZ&fDF~<+eYA$3LN!45#w}&ujU<c?DY9f?Vf~h(U(t}s%1s*eu~Dx<a_dNfH60um z#`T_LD_Sk?SwhS!P!uyLASf|hQk4ifN0yTIC2CcX6@@SY#0_T*UnQQC|3{h;rJ`X) zpmU|)(~CfHO#7|rpU`!AT8Xa^*k@6!NsrYNj5}zY8#@i0zTYb-r*cV|5ax~UM{-B* zAmXHxOVCMOv#_`6Z6#8&*i!Sk0p-`zGa@wMe8B9<)jNrCP3juayYGsO(-ig!Z;ar{ z;+ZIrrV7W8-h%!y`xs^!7$0d6ZLn$gW7#BYQC3GP0URSOX-7%1?RbmbJ3Z{(wTJYA zcZXTWVLOo?%V-1Fh3pN#Oj<JXrae%ROpm``or`hJs}+S2*Y2Xl!}@@@9bVvz3~rtO zVInLhV#9#cnGLiL!lG7<{ZQRGeYSZdLs8MFMe`2UEMlCH(AR{DC3ff=n6nyyjobyd zIzfK(#`&KM4=ftS=BIx8LnSfzcqOH7*yy>~{>}wAp%2}NZ$r$<uOQx`Ns8WlbUPU~ zWw83&K-dD=L99+f?!ptZGUhXpn^RsrTsD6y;aaN&XZofaUEtG(88RwEoeDh`0+q~- zz=tFStcpxOMnAXwnLDe+xr?<wpO;LkWv>W5kCUg+XxFrb_JxiG2vMDtI{6$xsWgvd zMj=o8>A@OMIDRU3;$?zNiAt5pRH3#OTXiAA%BFzk+H+u4W$EjyCie7w>rNdm%r}&e zJUQV+m{g<LQ7<xTjfKCZ+3<doc{1<)eOMvup(4?*BOYw?aM+gsl{HJ0PK4$UgIAcG z9gg&9SxaqpH_e0C|DBpPw<SQ}u;5ZP;l7&D=k!_m{iQ>;7?VV-vbNtT+v9DJiTVdD z)}8qGp}!<zf~2Ab7zD^Maa6$kgs6DQb=_c6#EfWJTNlriv`SVEeO=4ix<2#%()4bd zJ#)?;tXRt3MXBfIhlg=kD8$)Bp%rP9bkiQ6bfb9svj65!mZpB{BH2Og;1bbwJ<Fz< z@zmtY4=OkAjQaS<9<c2}-4g$@dhgE|J)n2zs#y7iJ}Oz{`?IYsyRMKrbO{09KgmOD zJ_F^uGbAl$%gox#NzXzFU>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;2M<MG@3LVFdJLqvMAs1X`a32i$+mtM=P z`PL)L!d8X`3=G{i;UAhcqkFRO4(i=f`?JSqF#w4G=5tbxRR4fmm$^zFQ~H-dyPMD; zBSA?7Zr$r<WD>s+<ev^JQWHGSp^!%O-?>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#(<rGewc1-P>^JJ>o#Q?2W!YKVrFp|9%$eIMabV04c;REm ztCKrOXCAELnG|t-$9D55CFU@%>c33YVmWynlw^e*>aPizaZ@|de84})!<VepT(d30 z@%uwaM$}F!P~H?nn%%X%sHA|~nFs2oBH47jonrOs(kj?0lR5SN;e+#lcE@FO-qKpz z92(@L`he(wDgz1b|0uRVD_8$PcJ*qrf|SVE?(cR+3TE3EEf3ezKjPSkS)K@gOTS%q zwONu2uDL{+d`MUl<d{IG<Qrw<JsMdhYamuWK=l728as2QVs3GsE;*@hjb)P7HZ&Y; zRQdsN({Nrzw6ipu-tstrV?P8B^<~3B3=5MyJEuJo1}uXXPjxxnU@j)8_dghA4mp|j z3WeOlAQ~Do)AgCXbdK-D;jFP~lf&->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^<zun6b^i1e?4=WA*d+)k6b}r zLkYK!V5*9*dtxaSutJY3<nXz}=&b#UeG`cIt9hC@ZO_ixCsGXAlr%*y#$)ODB1^K^ zXiR41_V&0$>)2gERZ(~c0;d6${*9M%qQ<M%{%q?A`9FH4Y22g}ZVO!LN;7zA)c$>3 zIqJCbb`Z`xtpxCDEre8J^CP&>oOuC60rO4lWlcOjk~N{45_C3;R#B}~Q8mucs=%+Z zw*8Zl5`$EEkQ@Mp3UhM8d?}BzRp{Gcx|56<dL+>*xQYzn3eXri+Mq;Kpn}P};Ht7J zNG6~#;JsCr4FluJ<0>gYc?zbk>HXd4<VeaBj%|!FpL8A^7n|Q%Xln83oOu?pih5O2 zpZh;pE-aB(G_5!uVtfs+IWGj`D1rHj(Wc1I93bSo%NOcA?LPE(TuyeyWrgNmBc!`d z5JN6LUec&1+i@t6Q+&QXy%!U_k<~qOVZiW2DF32m*Pv71bM|BWgMgKyK)52*GZ3!n zvGe-w`w7Maaaz>aF&mAJn!2*`Bm;;D&R1S+?_r_HU<HEwzyJ;}|JI$s_M_Gu5mv-k z({r}DTSlr<R#v2K?9uMq)l!hyu}NW8?v|CxIB>zZef3@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+<mjizT?$#p4rK{D(eAu6JEKkyM?L2u=LX#r}4hG zKecc{Y;~8--g^U@6ph|r?@8xvR<<1CVsGs-o8{Njf$?dQH+EE4g(sHTMvO>=(I*NE z@Q$&+iE)BF?Umwq)RxCfZDEU~v*!mH$Z^m)$q!>UP~N%C1ntzbWxJ$RmJzufTpu9C zr!owncmAzQL<FGMe?3^<VsA-jX(!UN3wd*=?@q_>c>jS>Qj$@8xNKwln4(varG081 z$=8cPO-<A|R)Y%ZBqh=90H6;Lroh+DnE}EDZFll_Nj<|TVrOD~{ngLW`j{u5zQRHa zO~k|L6Q3Mi;;`&DMkqo(=kpzjxDycngN_jN2eG`knna(%FoPzGl(pBa70-#P%2690 zTqJ*<CT8IMBhA_qS@Gie?DznvLa@8?cyH4HBvlj``Z`C~TyvzH>>Cu;4jY4YRxW*k zjd|FaUo2gGl)Do0xz{e~&}^ZXfgY6XQ6k1?+%13JvhJKca^?PI8o|TI%s$@l^aZu) zk<sDknph@UZ|PrV!iR>-dP=v4cQ^vS@8++gxdbvB?0>P^qMa>>tt|a&L*m7UY{kfI zXhi3lYHDHVSy((jU1J==J7_<^0b60IF}O%NJODOMVMx63YhIL)_9w5~;WaDY?<CA+ zc|Ev^bSb|ugJCnm<1aD!_Qmt&TU=}utFGo-Z;=EgOoIDWn&(vld%_IM>1m)X1?n-< z0d}{_?}@W@et(A+GUuZO!1TNb+5<@#Jkjg0g+-pTNrZ?B)K=Y<Q4Q}6`i}OcGN;Ev z$@KZ#i!tR=5LKZGOH^p*2j`Bw?V)oOw&;Y9Q3@=dE47}m24}Ev;u7WYkxU3|X2m+G zXLKc3W=F5=@M>%uyxWShfttLZTtR~ZZ%9b?y3mz1nnCEi7>@wpZK8&MUm08<y7k3U z^}mEcEMuxN?gz(8>S&&a-UQ;v`S19mK@;5981)NJ^iH#gz;=A;t=(R9RQA?P{F-vu zh=e5LtF?AK#@2>q#cEoJaLfA|V?0(qB@$l?6<SYu-|xue-kWO|QMImWc*BPX5n5P} z5WQRsXjkdY8O?b_d`e#)iPg4;2-s-Sp%|>xgjIj566Zsfqz1i~xS}pLxYSgktbHzi zmCCxzV4j+Y`tXttAGfu~LXW*84tY=U;8Ir*Ql9a<CI2VSV6#Gr`qy2d(M2_VhG!{k z#EJ6C{i{kv=9S2BnDXi9y}T590Cml}Q48op$@3m>a6b7^?~996AtKDL19Or!L-D+{ zh#aCFZ7G<149ug!LQBhm^PVIfiuccg$Pi8scukZ581fO3qjq$8#lCVL0b<uoYk+Un z*PJuQ#gFKkT}ZnPcj??DoFdJdq2CH$<Wc=FN>#)({vdk9GY7P+nr31yVI%oZHox22 zA;1l|`g|-<%e-Tigfu?aS-!L!Da#(_?R?1m&WUGYW&Kmr52hcJ9ATy*owCEOVluK= zo6<e%Y6@MC7bNg;6Z?d2mp}KK@+T{URIpp>S{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#<Txw7FIq3FrM5&9>h$gHN7al~<b@+QHraZ$3?IWAAo! zZ;$POSlD=dRyyNvgxAkxkPb{n@e#$@BW!u&4kJr$EDeTBad09}j=*U#NtMIu`l`S> z_*uD+%(6i6#f9@TcYTFPa`DpPg%Ek;YQ3pQqsNi9tg;V#uo1Ry88t{xLXD+cY;FB# zCrMzZEVcsa;fuZK_+Qq~Uog)`ug2vE%jWl<YepnEMlQULGwyEc=8;^F@0(zzi6o%` zT?}XeE9ZjG#YHzdcIT|SeU%Jcw{BknNH`Htl}8Yi?^SLJ>0>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^|#A<EygLMd@knPq1}JqkO`Ah!OX*vmXP;r z>r8M%^4D05c&(R{S~!ThT*$3+HcYIX8TS_>|9~AX?<yac*1i-70K{XT>TOXBcwm=1 z$oY@tkah_>;ZBrm89VSa+D5?7Mkja{7aYp<(KA((sYwRDr@;6SW2YNfy7kax;mtJ~ zcb<BIv+W#LcwaWzn<nYgEmgiVQN;t0A?>D>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<fj9>~p-sx^}e-VdmH@!>mGa6icfm-7uK%_H>&l zP%b<f`?s$!?q1t>A$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@cxItGk<Y-?5XyZ#2j7^$$&yuhJ@tS)?p% z-(qBby_KQ6e)C)bxfEV5oh7TWZYaA6p?E<R?cC>CSSo9QEyKcZY%`(;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}mK<NdvEy<=SgRfG2o<WNPU@d2=`$87 zD7$*aTHveajJ1M5aepVfhJkH(5N5q5t!BC}+9nkXhg$!Q?S_a&W8nQ%v#69@9{|56 zjI853*#Cp-kvu~euO`tJqi6-wqoF_NlZu#MwdN$gPnDsFc$`56Z`(GKh5C1L@fD)O zjOFW5JhwS+@O4q@#?1!=EO03!WjqEn*AI2k_Q{E2T#o(`Fj+h%xEa)LNpFe?>X?ky zFM5pcYw3SFA=&-pv(RdFNsC6<vmeP_dn6r?XE~Pa%i~^Qjb+BGFl-D7Q5XXOa!S{W zRK%m`%#wy$#!JfB5jE3GQWuROXu!X_i0aw}H6|tf#<BG`+iOzI%+}^1K3-Ewq?NFJ zzhESwy#YyOk3tPs1?;|F4guk8{=AM>;3}1y;d`qjyL84fk>;$Rby|U4mU~Ub-2Ss^ z^`}WIL<eUCKTkTVJ@(qdt{NzM-=S_2%j5ct14-IAa7!VQtsjfHUg320EH9Q0e*DU7 zt^M3s-{IB{-&J=E9~r$Tpe)Aj7VB*My7jibQyaVGiw&PL5++}4blE%V2mPXG1$YPY z;7^;nlaO^<yN3X3hc4%DQ@=#yuu|qfTg}>zcBuiQXUd45!0b*n#<FNjs|Lyhs%~9K z5^+}4d4i5(SaiWuePA63B%IbolO}&2r0>}CD+>VfzrZgdlcyiSW0Nua1``ZlxdVQr zk!qo4U<|zFbE!qdbF>$zVeB=%tsUt1{y|fqr>gQF5hyo!VRiUm6S~5F&l8siefoCi zuFhasu07gnvo~t|mb{2h&d!j=C1Y-MDJAinrcI<sk-P$KGsWuYMMKrAsSaJPX6aB) z<VVh7y57O$Z%|n5TvrJLh-Q=TkStbO@`)!hTafH;<>w3@6}cj?u~191om3C!=qiez z#v7G9K<Jx9jyX@|ZvL0f^UmJZ*l6wn8KCH8Y%*N%!c{k&cHfuR3P~_Pl;r$+AK0h9 zG}beZM=cYfIRL=bBAvj6DOHL|$k5`w)vJeHkk@R=fAaTmC6etE6GI{@rN$Ip@g2<% zQlqR>+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^<a-C^Pyruuj z?AmOx3SSU!2+io!z%!!3=YCPo*}KDY?5g>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<fF9sj3gXWGS4r-s7Pr9u$!fAN9VPd>}!?EN`uOIN5y@GcawB!TvTru z>7MkBV^`n}MPT#dMjqdT$1;r&-XW4&+P|g8dn-qGSw@RQ{JzZ9(=@>|pC0yN;%7A= zcUZRs)PzgHZgqT*mbbGbE}|<nX%+&-erE^Z4gQ8$uSYCEyvFvc|7_rGbx+zh`k@y> 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<SmDT`N-sVqC znolOo*1NYOIl8DGd4K;BIZa5iF@B{cN*0c9IAX7U>;Rk<g59n;G+KZuX0A!u>Q?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{#apqddQ<P_L#oIqU^m zQcDKpP#ACl2?Stbdt{0_7ZbKt>XBNOJ8Jwx{~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({ersq<a_k?s5wzS7OT+7ZZ=(II;#hO5kQwatb8 z4Tmqy(h<eZ!?TcL)m;%DqDw%jC76k{5@5W0n+a_UD%FmJ*S{lZ(!#F#*A17QSRUj4 z)#i_v6ruUSExpb9=~Bg2mJRfzr854Vp_`n4WDVVV8k$XAv!dM(L^@IGIIat_<&WPS zcotgTyDFvFezP8ZQq7d1A;a(AL4Fuq$P^3;rK9l-^ie7Qw)X|3YthI>7`a!`$3v7R zgMn2o7L3K$P&1;fRqImY;(bBPceXKHeiY$|()xQt<EU|DI#k9_jCS%Z=Slk@?I)vu z9{ls_nK~++fWW<7`pI`{a(d{g>d2$3-XFlKtyrvdKa;Kv(?7oyg~8>*Q?d>I8{rAN z2mH9(geFe<q5cS?>zLXF?c~&7U^Mu@4*bi28)O|^7P;Gt@nv)*2jNEu7<z(s*HY`o z7&@<cX$ISm#}Kv1%hDU;uM>6PK$F~z9t6N=P(K4#rL40GLbwsWK<7X*+jL%jL;9FO zdrLj7fZrid#qiEP{kQI09#|o8Pb}v<PDbskwkO1O*d1iSTP7L`mvc)fdFBDpH%D1L zu?*26z=U-iP_TP?eq;rd`Q!#$+b$&rJnu&b_S>88_)6xF<jXzIzOB{9;T~hemv=6N zb*5?4BO-8A)epiIbt}&A6wMZ_fl&HK*Dvh8%IR`M&>Ei_=*CeLsA-JOt<U{VRQH8M zV>^)4jTfDKI3jxR3ys?^jbM?*lw<U;bHT2pp88U2b!y8-%4RjcKsH<F4gw(*{C5`M zB4V<D@WDX)9Z>mlxSEffKLU0l7P;71CGsstmPpx1pm48eoyM&OR8baDAYmwg6@}yq z)&jn`3<|Bp+W}lR{(49lFhpnD<AaMw;<8P&4chZR69fdcXV3)c$)x<-xw}$RR_P<P z-TP-G2>xGY#$+Cg;fT>hnCK=seo8SMakR>1=(I$P?5~Q3r4ReR0XxEDLNG?|m+i}W zf<SAT?DUlm^K<<B4PhXY#!!B!;1Lc4pKF>odpWkcBdOWI$=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={)H<K?(shZHH`bxnY!LO^Pa91h3Uo3Ac z;YYs1tl?es7nIoi5Sn<q$X7dUlpQZ#E3~;Y`n`S5pva>D57DP}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<M&-cOvE1*GYGz4nhZK5L)sN=H=28OW)vPw3t~dtMBG$_ z$rL76Rw5qTVZQrp2Y}&C)xL{<v$t}LfW5+rjmkxu2~L7Q$CWxSHl(Xs)dc7U^rI|> zhNBCHur9`L!fCzaH+okmn%14t{8ednI54<gF)~jRrFlKqFwKi4x|N5y5prdH>Ys+N zyt~LmBC=v!$!fv{h?_=+C1>H#@<{oS_=)%_K8~*nd!x1<{36_4K~+TvRhQ56<vO<{ z^m8GHF`KN-mEd_q=mws|G4D1j3{CS<?hc6h*-iLhcue>t@!15HTxsKi=o^Lvr7`NT z^6ihlviy+Kp3qnWP6ynIhGZH4m8G;K>~lH&vR+L4<ZDdD!odZ0=mE~~fS@ue(p8cs G0sjMgjHl56 literal 0 HcmV?d00001 diff --git a/themes/triangles/client/src/img/stores/.directory b/themes/triangles/client/src/img/stores/.directory new file mode 100644 index 00000000..7bdc8daf --- /dev/null +++ b/themes/triangles/client/src/img/stores/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,17,20,57,25 +Version=3 +ViewMode=1 diff --git a/themes/triangles/client/src/img/stores/applestore-badge.svg b/themes/triangles/client/src/img/stores/applestore-badge.svg new file mode 100644 index 00000000..ac111e59 --- /dev/null +++ b/themes/triangles/client/src/img/stores/applestore-badge.svg @@ -0,0 +1,129 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg version="1.1" id="US_UK_Download_on_the" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" + x="0px" y="0px" width="135px" height="40px" viewBox="0 0 135 40" enable-background="new 0 0 135 40" xml:space="preserve"> +<g> + <path fill="#A6A6A6" d="M130.197,40H4.729C2.122,40,0,37.872,0,35.267V4.726C0,2.12,2.122,0,4.729,0h125.468 + C132.803,0,135,2.12,135,4.726v30.541C135,37.872,132.803,40,130.197,40L130.197,40z"/> + <path d="M134.032,35.268c0,2.116-1.714,3.83-3.834,3.83H4.729c-2.119,0-3.839-1.714-3.839-3.83V4.725 + c0-2.115,1.72-3.835,3.839-3.835h125.468c2.121,0,3.834,1.72,3.834,3.835L134.032,35.268L134.032,35.268z"/> + <g> + <g> + <path fill="#FFFFFF" d="M30.128,19.784c-0.029-3.223,2.639-4.791,2.761-4.864c-1.511-2.203-3.853-2.504-4.676-2.528 + c-1.967-0.207-3.875,1.177-4.877,1.177c-1.022,0-2.565-1.157-4.228-1.123c-2.14,0.033-4.142,1.272-5.24,3.196 + c-2.266,3.923-0.576,9.688,1.595,12.859c1.086,1.553,2.355,3.287,4.016,3.226c1.625-0.067,2.232-1.036,4.193-1.036 + c1.943,0,2.513,1.036,4.207,0.997c1.744-0.028,2.842-1.56,3.89-3.127c1.255-1.78,1.759-3.533,1.779-3.623 + C33.507,24.924,30.161,23.647,30.128,19.784z"/> + <path fill="#FFFFFF" d="M26.928,10.306c0.874-1.093,1.472-2.58,1.306-4.089c-1.265,0.056-2.847,0.875-3.758,1.944 + c-0.806,0.942-1.526,2.486-1.34,3.938C24.557,12.205,26.016,11.382,26.928,10.306z"/> + </g> + </g> + <g> + <path fill="#FFFFFF" d="M53.645,31.504h-2.271l-1.244-3.909h-4.324l-1.185,3.909h-2.211l4.284-13.308h2.646L53.645,31.504z + M49.755,25.955L48.63,22.48c-0.119-0.355-0.342-1.191-0.671-2.507h-0.04c-0.131,0.566-0.342,1.402-0.632,2.507l-1.105,3.475 + H49.755z"/> + <path fill="#FFFFFF" d="M64.662,26.588c0,1.632-0.441,2.922-1.323,3.869c-0.79,0.843-1.771,1.264-2.942,1.264 + c-1.264,0-2.172-0.454-2.725-1.362h-0.04v5.055h-2.132V25.067c0-1.026-0.027-2.079-0.079-3.159h1.875l0.119,1.521h0.04 + c0.711-1.146,1.79-1.718,3.238-1.718c1.132,0,2.077,0.447,2.833,1.342C64.284,23.949,64.662,25.127,64.662,26.588z M62.49,26.666 + c0-0.934-0.21-1.704-0.632-2.31c-0.461-0.632-1.08-0.948-1.856-0.948c-0.526,0-1.004,0.176-1.431,0.523 + c-0.428,0.35-0.708,0.807-0.839,1.373c-0.066,0.264-0.099,0.48-0.099,0.65v1.6c0,0.698,0.214,1.287,0.642,1.768 + s0.984,0.721,1.668,0.721c0.803,0,1.428-0.31,1.875-0.928C62.266,28.496,62.49,27.68,62.49,26.666z"/> + <path fill="#FFFFFF" d="M75.699,26.588c0,1.632-0.441,2.922-1.324,3.869c-0.789,0.843-1.77,1.264-2.941,1.264 + c-1.264,0-2.172-0.454-2.724-1.362H68.67v5.055h-2.132V25.067c0-1.026-0.027-2.079-0.079-3.159h1.875l0.119,1.521h0.04 + c0.71-1.146,1.789-1.718,3.238-1.718c1.131,0,2.076,0.447,2.834,1.342C75.32,23.949,75.699,25.127,75.699,26.588z M73.527,26.666 + c0-0.934-0.211-1.704-0.633-2.31c-0.461-0.632-1.078-0.948-1.855-0.948c-0.527,0-1.004,0.176-1.432,0.523 + c-0.428,0.35-0.707,0.807-0.838,1.373c-0.065,0.264-0.099,0.48-0.099,0.65v1.6c0,0.698,0.214,1.287,0.64,1.768 + c0.428,0.48,0.984,0.721,1.67,0.721c0.803,0,1.428-0.31,1.875-0.928C73.303,28.496,73.527,27.68,73.527,26.666z"/> + <path fill="#FFFFFF" d="M88.039,27.772c0,1.132-0.393,2.053-1.182,2.764c-0.867,0.777-2.074,1.165-3.625,1.165 + c-1.432,0-2.58-0.276-3.449-0.829l0.494-1.777c0.936,0.566,1.963,0.85,3.082,0.85c0.803,0,1.428-0.182,1.877-0.544 + c0.447-0.362,0.67-0.848,0.67-1.454c0-0.54-0.184-0.995-0.553-1.364c-0.367-0.369-0.98-0.712-1.836-1.029 + c-2.33-0.869-3.494-2.142-3.494-3.816c0-1.094,0.408-1.991,1.225-2.689c0.814-0.699,1.9-1.048,3.258-1.048 + c1.211,0,2.217,0.211,3.02,0.632l-0.533,1.738c-0.75-0.408-1.598-0.612-2.547-0.612c-0.75,0-1.336,0.185-1.756,0.553 + c-0.355,0.329-0.533,0.73-0.533,1.205c0,0.526,0.203,0.961,0.611,1.303c0.355,0.316,1,0.658,1.936,1.027 + c1.145,0.461,1.986,1,2.527,1.618C87.77,26.081,88.039,26.852,88.039,27.772z"/> + <path fill="#FFFFFF" d="M95.088,23.508h-2.35v4.659c0,1.185,0.414,1.777,1.244,1.777c0.381,0,0.697-0.033,0.947-0.099l0.059,1.619 + c-0.42,0.157-0.973,0.236-1.658,0.236c-0.842,0-1.5-0.257-1.975-0.77c-0.473-0.514-0.711-1.376-0.711-2.587v-4.837h-1.4v-1.6h1.4 + v-1.757l2.094-0.632v2.389h2.35V23.508z"/> + <path fill="#FFFFFF" d="M105.691,26.627c0,1.475-0.422,2.686-1.264,3.633c-0.883,0.975-2.055,1.461-3.516,1.461 + c-1.408,0-2.529-0.467-3.365-1.401s-1.254-2.113-1.254-3.534c0-1.487,0.43-2.705,1.293-3.652c0.861-0.948,2.023-1.422,3.484-1.422 + c1.408,0,2.541,0.467,3.396,1.402C105.283,24.021,105.691,25.192,105.691,26.627z M103.479,26.696 + c0-0.885-0.189-1.644-0.572-2.277c-0.447-0.766-1.086-1.148-1.914-1.148c-0.857,0-1.508,0.383-1.955,1.148 + c-0.383,0.634-0.572,1.405-0.572,2.317c0,0.885,0.189,1.644,0.572,2.276c0.461,0.766,1.105,1.148,1.936,1.148 + c0.814,0,1.453-0.39,1.914-1.168C103.281,28.347,103.479,27.58,103.479,26.696z"/> + <path fill="#FFFFFF" d="M112.621,23.783c-0.211-0.039-0.436-0.059-0.672-0.059c-0.75,0-1.33,0.283-1.738,0.85 + c-0.355,0.5-0.533,1.132-0.533,1.895v5.035h-2.131l0.02-6.574c0-1.106-0.027-2.113-0.08-3.021h1.857l0.078,1.836h0.059 + c0.225-0.631,0.58-1.139,1.066-1.52c0.475-0.343,0.988-0.514,1.541-0.514c0.197,0,0.375,0.014,0.533,0.039V23.783z"/> + <path fill="#FFFFFF" d="M122.156,26.252c0,0.382-0.025,0.704-0.078,0.967h-6.396c0.025,0.948,0.334,1.673,0.928,2.173 + c0.539,0.447,1.236,0.671,2.092,0.671c0.947,0,1.811-0.151,2.588-0.454l0.334,1.48c-0.908,0.396-1.98,0.593-3.217,0.593 + c-1.488,0-2.656-0.438-3.506-1.313c-0.848-0.875-1.273-2.05-1.273-3.524c0-1.447,0.395-2.652,1.186-3.613 + c0.828-1.026,1.947-1.539,3.355-1.539c1.383,0,2.43,0.513,3.141,1.539C121.873,24.047,122.156,25.055,122.156,26.252z + M120.123,25.699c0.014-0.632-0.125-1.178-0.414-1.639c-0.369-0.593-0.936-0.889-1.699-0.889c-0.697,0-1.264,0.289-1.697,0.869 + c-0.355,0.461-0.566,1.014-0.631,1.658H120.123z"/> + </g> + <g> + <g> + <path fill="#FFFFFF" d="M49.05,10.009c0,1.177-0.353,2.063-1.058,2.658c-0.653,0.549-1.581,0.824-2.783,0.824 + c-0.596,0-1.106-0.026-1.533-0.078V6.982c0.557-0.09,1.157-0.136,1.805-0.136c1.145,0,2.008,0.249,2.59,0.747 + C48.723,8.156,49.05,8.961,49.05,10.009z M47.945,10.038c0-0.763-0.202-1.348-0.606-1.756c-0.404-0.407-0.994-0.611-1.771-0.611 + c-0.33,0-0.611,0.022-0.844,0.068v4.889c0.129,0.02,0.365,0.029,0.708,0.029c0.802,0,1.421-0.223,1.857-0.669 + S47.945,10.892,47.945,10.038z"/> + <path fill="#FFFFFF" d="M54.909,11.037c0,0.725-0.207,1.319-0.621,1.785c-0.434,0.479-1.009,0.718-1.727,0.718 + c-0.692,0-1.243-0.229-1.654-0.689c-0.41-0.459-0.615-1.038-0.615-1.736c0-0.73,0.211-1.329,0.635-1.794s0.994-0.698,1.712-0.698 + c0.692,0,1.248,0.229,1.669,0.688C54.708,9.757,54.909,10.333,54.909,11.037z M53.822,11.071c0-0.435-0.094-0.808-0.281-1.119 + c-0.22-0.376-0.533-0.564-0.94-0.564c-0.421,0-0.741,0.188-0.961,0.564c-0.188,0.311-0.281,0.69-0.281,1.138 + c0,0.435,0.094,0.808,0.281,1.119c0.227,0.376,0.543,0.564,0.951,0.564c0.4,0,0.714-0.191,0.94-0.574 + C53.725,11.882,53.822,11.506,53.822,11.071z"/> + <path fill="#FFFFFF" d="M62.765,8.719l-1.475,4.714h-0.96l-0.611-2.047c-0.155-0.511-0.281-1.019-0.379-1.523h-0.019 + c-0.091,0.518-0.217,1.025-0.379,1.523l-0.649,2.047h-0.971l-1.387-4.714h1.077l0.533,2.241c0.129,0.53,0.235,1.035,0.32,1.513 + h0.019c0.078-0.394,0.207-0.896,0.389-1.503l0.669-2.25h0.854l0.641,2.202c0.155,0.537,0.281,1.054,0.378,1.552h0.029 + c0.071-0.485,0.178-1.002,0.32-1.552l0.572-2.202H62.765z"/> + <path fill="#FFFFFF" d="M68.198,13.433H67.15v-2.7c0-0.832-0.316-1.248-0.95-1.248c-0.311,0-0.562,0.114-0.757,0.343 + c-0.193,0.229-0.291,0.499-0.291,0.808v2.796h-1.048v-3.366c0-0.414-0.013-0.863-0.038-1.349h0.921l0.049,0.737h0.029 + c0.122-0.229,0.304-0.418,0.543-0.569c0.284-0.176,0.602-0.265,0.95-0.265c0.44,0,0.806,0.142,1.097,0.427 + c0.362,0.349,0.543,0.87,0.543,1.562V13.433z"/> + <path fill="#FFFFFF" d="M71.088,13.433h-1.047V6.556h1.047V13.433z"/> + <path fill="#FFFFFF" d="M77.258,11.037c0,0.725-0.207,1.319-0.621,1.785c-0.434,0.479-1.01,0.718-1.727,0.718 + c-0.693,0-1.244-0.229-1.654-0.689c-0.41-0.459-0.615-1.038-0.615-1.736c0-0.73,0.211-1.329,0.635-1.794s0.994-0.698,1.711-0.698 + c0.693,0,1.248,0.229,1.67,0.688C77.057,9.757,77.258,10.333,77.258,11.037z M76.17,11.071c0-0.435-0.094-0.808-0.281-1.119 + c-0.219-0.376-0.533-0.564-0.939-0.564c-0.422,0-0.742,0.188-0.961,0.564c-0.188,0.311-0.281,0.69-0.281,1.138 + c0,0.435,0.094,0.808,0.281,1.119c0.227,0.376,0.543,0.564,0.951,0.564c0.4,0,0.713-0.191,0.939-0.574 + C76.074,11.882,76.17,11.506,76.17,11.071z"/> + <path fill="#FFFFFF" d="M82.33,13.433h-0.941l-0.078-0.543h-0.029c-0.322,0.433-0.781,0.65-1.377,0.65 + c-0.445,0-0.805-0.143-1.076-0.427c-0.246-0.258-0.369-0.579-0.369-0.96c0-0.576,0.24-1.015,0.723-1.319 + c0.482-0.304,1.16-0.453,2.033-0.446V10.3c0-0.621-0.326-0.931-0.979-0.931c-0.465,0-0.875,0.117-1.229,0.349l-0.213-0.688 + c0.438-0.271,0.979-0.407,1.617-0.407c1.232,0,1.85,0.65,1.85,1.95v1.736C82.262,12.78,82.285,13.155,82.33,13.433z + M81.242,11.813v-0.727c-1.156-0.02-1.734,0.297-1.734,0.95c0,0.246,0.066,0.43,0.201,0.553c0.135,0.123,0.307,0.184,0.512,0.184 + c0.23,0,0.445-0.073,0.641-0.218c0.197-0.146,0.318-0.331,0.363-0.558C81.236,11.946,81.242,11.884,81.242,11.813z"/> + <path fill="#FFFFFF" d="M88.285,13.433h-0.93l-0.049-0.757h-0.029c-0.297,0.576-0.803,0.864-1.514,0.864 + c-0.568,0-1.041-0.223-1.416-0.669s-0.562-1.025-0.562-1.736c0-0.763,0.203-1.381,0.611-1.853c0.395-0.44,0.879-0.66,1.455-0.66 + c0.633,0,1.076,0.213,1.328,0.64h0.02V6.556h1.049v5.607C88.248,12.622,88.26,13.045,88.285,13.433z M87.199,11.445v-0.786 + c0-0.136-0.01-0.246-0.029-0.33c-0.059-0.252-0.186-0.464-0.379-0.635c-0.195-0.171-0.43-0.257-0.701-0.257 + c-0.391,0-0.697,0.155-0.922,0.466c-0.223,0.311-0.336,0.708-0.336,1.193c0,0.466,0.107,0.844,0.322,1.135 + c0.227,0.31,0.533,0.465,0.916,0.465c0.344,0,0.619-0.129,0.828-0.388C87.1,12.069,87.199,11.781,87.199,11.445z"/> + <path fill="#FFFFFF" d="M97.248,11.037c0,0.725-0.207,1.319-0.621,1.785c-0.434,0.479-1.008,0.718-1.727,0.718 + c-0.691,0-1.242-0.229-1.654-0.689c-0.41-0.459-0.615-1.038-0.615-1.736c0-0.73,0.211-1.329,0.635-1.794s0.994-0.698,1.713-0.698 + c0.691,0,1.248,0.229,1.668,0.688C97.047,9.757,97.248,10.333,97.248,11.037z M96.162,11.071c0-0.435-0.094-0.808-0.281-1.119 + c-0.221-0.376-0.533-0.564-0.941-0.564c-0.42,0-0.74,0.188-0.961,0.564c-0.188,0.311-0.281,0.69-0.281,1.138 + c0,0.435,0.094,0.808,0.281,1.119c0.227,0.376,0.543,0.564,0.951,0.564c0.4,0,0.715-0.191,0.941-0.574 + C96.064,11.882,96.162,11.506,96.162,11.071z"/> + <path fill="#FFFFFF" d="M102.883,13.433h-1.047v-2.7c0-0.832-0.316-1.248-0.951-1.248c-0.311,0-0.562,0.114-0.756,0.343 + s-0.291,0.499-0.291,0.808v2.796h-1.049v-3.366c0-0.414-0.012-0.863-0.037-1.349h0.92l0.049,0.737h0.029 + c0.123-0.229,0.305-0.418,0.543-0.569c0.285-0.176,0.602-0.265,0.951-0.265c0.439,0,0.805,0.142,1.096,0.427 + c0.363,0.349,0.543,0.87,0.543,1.562V13.433z"/> + <path fill="#FFFFFF" d="M109.936,9.504h-1.154v2.29c0,0.582,0.205,0.873,0.611,0.873c0.188,0,0.344-0.016,0.467-0.049 + l0.027,0.795c-0.207,0.078-0.479,0.117-0.814,0.117c-0.414,0-0.736-0.126-0.969-0.378c-0.234-0.252-0.35-0.676-0.35-1.271V9.504 + h-0.689V8.719h0.689V7.855l1.027-0.31v1.173h1.154V9.504z"/> + <path fill="#FFFFFF" d="M115.484,13.433h-1.049v-2.68c0-0.845-0.316-1.268-0.949-1.268c-0.486,0-0.818,0.245-1,0.735 + c-0.031,0.103-0.049,0.229-0.049,0.377v2.835h-1.047V6.556h1.047v2.841h0.02c0.33-0.517,0.803-0.775,1.416-0.775 + c0.434,0,0.793,0.142,1.078,0.427c0.355,0.355,0.533,0.883,0.533,1.581V13.433z"/> + <path fill="#FFFFFF" d="M121.207,10.853c0,0.188-0.014,0.346-0.039,0.475h-3.143c0.014,0.466,0.164,0.821,0.455,1.067 + c0.266,0.22,0.609,0.33,1.029,0.33c0.465,0,0.889-0.074,1.271-0.223l0.164,0.728c-0.447,0.194-0.973,0.291-1.582,0.291 + c-0.73,0-1.305-0.215-1.721-0.645c-0.418-0.43-0.625-1.007-0.625-1.731c0-0.711,0.193-1.303,0.582-1.775 + c0.406-0.504,0.955-0.756,1.648-0.756c0.678,0,1.193,0.252,1.541,0.756C121.068,9.77,121.207,10.265,121.207,10.853z + M120.207,10.582c0.008-0.311-0.061-0.579-0.203-0.805c-0.182-0.291-0.459-0.437-0.834-0.437c-0.342,0-0.621,0.142-0.834,0.427 + c-0.174,0.227-0.277,0.498-0.311,0.815H120.207z"/> + </g> + </g> +</g> +</svg> diff --git a/themes/triangles/client/src/img/stores/googleplay-badge.svg b/themes/triangles/client/src/img/stores/googleplay-badge.svg new file mode 100644 index 00000000..9e33e3aa --- /dev/null +++ b/themes/triangles/client/src/img/stores/googleplay-badge.svg @@ -0,0 +1,429 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + id="svg2" + version="1.1" + inkscape:version="0.91 r13725" + xml:space="preserve" + width="135.71649" + height="40.018951" + viewBox="0 0 135.71649 40.018951" + sodipodi:docname="google-play-badge.svg"><metadata + id="metadata8"><rdf:RDF><cc:Work + rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs + id="defs6"><linearGradient + x1="31.7997" + y1="183.2903" + x2="15.0173" + y2="166.5079" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.8,0,0,-0.8,0,161.6)" + spreadMethod="pad" + id="linearGradient50"><stop + style="stop-opacity:1;stop-color:#00a0ff" + offset="0" + id="stop52" /><stop + style="stop-opacity:1;stop-color:#00a1ff" + offset="0.0066" + id="stop54" /><stop + style="stop-opacity:1;stop-color:#00beff" + offset="0.2601" + id="stop56" /><stop + style="stop-opacity:1;stop-color:#00d2ff" + offset="0.5122" + id="stop58" /><stop + style="stop-opacity:1;stop-color:#00dfff" + offset="0.7604" + id="stop60" /><stop + style="stop-opacity:1;stop-color:#00e3ff" + offset="1" + id="stop62" /></linearGradient><linearGradient + x1="43.8344" + y1="171.9986" + x2="19.637501" + y2="171.9986" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.8,0,0,-0.8,0,161.6)" + spreadMethod="pad" + id="linearGradient68"><stop + style="stop-opacity:1;stop-color:#ffe000" + offset="0" + id="stop70" /><stop + style="stop-opacity:1;stop-color:#ffbd00" + offset="0.4087" + id="stop72" /><stop + style="stop-opacity:1;stop-color:#ffa500" + offset="0.7754" + id="stop74" /><stop + style="stop-opacity:1;stop-color:#ff9c00" + offset="1" + id="stop76" /></linearGradient><linearGradient + x1="34.827" + y1="169.7039" + x2="12.0687" + y2="146.9456" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.8,0,0,-0.8,0,161.6)" + spreadMethod="pad" + id="linearGradient82"><stop + style="stop-opacity:1;stop-color:#ff3a44" + offset="0" + id="stop84" /><stop + style="stop-opacity:1;stop-color:#c31162" + offset="1" + id="stop86" /></linearGradient><linearGradient + x1="17.2973" + y1="191.82381" + x2="27.4599" + y2="181.6613" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.8,0,0,-0.8,0,161.6)" + spreadMethod="pad" + id="linearGradient92"><stop + style="stop-opacity:1;stop-color:#32a071" + offset="0" + id="stop94" /><stop + style="stop-opacity:1;stop-color:#2da771" + offset="0.0685" + id="stop96" /><stop + style="stop-opacity:1;stop-color:#15cf74" + offset="0.4762" + id="stop98" /><stop + style="stop-opacity:1;stop-color:#06e775" + offset="0.8009" + id="stop100" /><stop + style="stop-opacity:1;stop-color:#00f076" + offset="1" + id="stop102" /></linearGradient><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath110"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path112" + inkscape:connector-curvature="0" /></clipPath><mask + maskUnits="userSpaceOnUse" + x="0" + y="0" + width="1" + height="1" + id="mask114"><g + id="g116"><g + clip-path="url(#clipPath110)" + id="g118"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + style="fill:#000000;fill-opacity:0.2;fill-rule:nonzero;stroke:none" + id="path120" + inkscape:connector-curvature="0" /></g></g></mask><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath126"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path128" + inkscape:connector-curvature="0" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath130"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path132" + inkscape:connector-curvature="0" /></clipPath><pattern + patternTransform="matrix(1,0,0,-1,0,48)" + patternUnits="userSpaceOnUse" + x="0" + y="0" + width="124" + height="48" + id="pattern134"><g + id="g136" /><g + id="g138"><g + clip-path="url(#clipPath130)" + id="g140"><g + id="g142"><path + d="M 29.625,20.695 18.012,14.098 C 17.363,13.727 16.781,13.754 16.406,14.09 l -0.058,-0.063 0.058,-0.058 c 0.375,-0.336 0.957,-0.36 1.606,0.011 l 11.687,6.641 -0.074,0.074 z" + style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path144" /></g></g></g></pattern><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath158"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path160" + inkscape:connector-curvature="0" /></clipPath><mask + maskUnits="userSpaceOnUse" + x="0" + y="0" + width="1" + height="1" + id="mask162"><g + id="g164"><g + clip-path="url(#clipPath158)" + id="g166"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + style="fill:#000000;fill-opacity:0.12000002;fill-rule:nonzero;stroke:none" + id="path168" + inkscape:connector-curvature="0" /></g></g></mask><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath174"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path176" + inkscape:connector-curvature="0" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath178"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path180" + inkscape:connector-curvature="0" /></clipPath><pattern + patternTransform="matrix(1,0,0,-1,0,48)" + patternUnits="userSpaceOnUse" + x="0" + y="0" + width="124" + height="48" + id="pattern182"><g + id="g184" /><g + id="g186"><g + clip-path="url(#clipPath178)" + id="g188"><g + id="g190"><path + d="m 16.348,14.145 c -0.235,0.246 -0.371,0.628 -0.371,1.125 l 0,-0.118 c 0,-0.496 0.136,-0.879 0.371,-1.125 l 0.058,0.063 -0.058,0.055 z" + style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path192" /></g></g></g></pattern><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath206"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path208" + inkscape:connector-curvature="0" /></clipPath><mask + maskUnits="userSpaceOnUse" + x="0" + y="0" + width="1" + height="1" + id="mask210"><g + id="g212"><g + clip-path="url(#clipPath206)" + id="g214"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + style="fill:#000000;fill-opacity:0.12000002;fill-rule:nonzero;stroke:none" + id="path216" + inkscape:connector-curvature="0" /></g></g></mask><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath222"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path224" + inkscape:connector-curvature="0" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath226"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path228" + inkscape:connector-curvature="0" /></clipPath><pattern + patternTransform="matrix(1,0,0,-1,0,48)" + patternUnits="userSpaceOnUse" + x="0" + y="0" + width="124" + height="48" + id="pattern230"><g + id="g232" /><g + id="g234"><g + clip-path="url(#clipPath226)" + id="g236"><g + id="g238"><path + d="m 33.613,22.961 -3.988,-2.266 0.074,-0.074 3.914,2.223 c 0.559,0.316 0.836,0.734 0.836,1.156 -0.047,-0.379 -0.332,-0.75 -0.836,-1.039 z" + style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path240" /></g></g></g></pattern><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath254"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path256" + inkscape:connector-curvature="0" /></clipPath><mask + maskUnits="userSpaceOnUse" + x="0" + y="0" + width="1" + height="1" + id="mask258"><g + id="g260"><g + clip-path="url(#clipPath254)" + id="g262"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + style="fill:#000000;fill-opacity:0.25;fill-rule:nonzero;stroke:none" + id="path264" + inkscape:connector-curvature="0" /></g></g></mask><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath270"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path272" + inkscape:connector-curvature="0" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath274"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + id="path276" + inkscape:connector-curvature="0" /></clipPath><pattern + patternTransform="matrix(1,0,0,-1,0,48)" + patternUnits="userSpaceOnUse" + x="0" + y="0" + width="124" + height="48" + id="pattern278"><g + id="g280" /><g + id="g282"><g + clip-path="url(#clipPath274)" + id="g284"><g + id="g286"><path + d="m 18.012,33.902 15.601,-8.863 c 0.508,-0.289 0.789,-0.66 0.836,-1.039 0,0.418 -0.277,0.836 -0.836,1.156 L 18.012,34.02 c -1.117,0.632 -2.035,0.105 -2.035,-1.176 l 0,-0.114 c 0,1.278 0.918,1.805 2.035,1.172 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path288" /></g></g></g></pattern></defs><sodipodi:namedview + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1" + objecttolerance="10" + gridtolerance="10" + guidetolerance="10" + inkscape:pageopacity="0" + inkscape:pageshadow="2" + inkscape:window-width="1366" + inkscape:window-height="705" + id="namedview4" + showgrid="false" + inkscape:zoom="7.6276974" + inkscape:cx="93.965168" + inkscape:cy="29.61582" + inkscape:window-x="-8" + inkscape:window-y="-8" + inkscape:window-maximized="1" + inkscape:current-layer="g10" /><g + id="g10" + inkscape:groupmode="layer" + inkscape:label="google-play-badge" + transform="matrix(1.25,0,0,-1.25,-9.4247625,49.85025)"><g + id="g12" + transform="matrix(1.0023923,0,0,0.99072975,-0.29664807,0)"><path + d="M 112,8 12,8 C 9.801,8 8,9.801 8,12 l 0,24 c 0,2.199 1.801,4 4,4 l 100,0 c 2.199,0 4,-1.801 4,-4 l 0,-24 c 0,-2.199 -1.801,-4 -4,-4 z" + style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path14" + inkscape:connector-curvature="0" /><path + d="m 112,39.359 c 1.852,0 3.359,-1.507 3.359,-3.359 l 0,-24 c 0,-1.852 -1.507,-3.359 -3.359,-3.359 l -100,0 c -1.852,0 -3.359,1.507 -3.359,3.359 l 0,24 c 0,1.852 1.507,3.359 3.359,3.359 l 100,0 M 112,40 12,40 C 9.801,40 8,38.199 8,36 L 8,12 C 8,9.801 9.801,8 12,8 l 100,0 c 2.199,0 4,1.801 4,4 l 0,24 c 0,2.199 -1.801,4 -4,4 z" + style="fill:#a6a6a6;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path16" + inkscape:connector-curvature="0" /><g + id="g18" + transform="matrix(1,0,0,-1,0,48)"><path + d="m 45.934,16.195 c 0,0.668 -0.2,1.203 -0.594,1.602 -0.453,0.473 -1.043,0.711 -1.766,0.711 -0.691,0 -1.281,-0.242 -1.765,-0.719 -0.485,-0.484 -0.727,-1.078 -0.727,-1.789 0,-0.711 0.242,-1.305 0.727,-1.785 0.484,-0.481 1.074,-0.723 1.765,-0.723 0.344,0 0.672,0.071 0.985,0.203 0.312,0.133 0.566,0.313 0.75,0.535 l -0.418,0.422 c -0.321,-0.379 -0.758,-0.566 -1.317,-0.566 -0.504,0 -0.941,0.176 -1.312,0.531 -0.367,0.356 -0.551,0.817 -0.551,1.383 0,0.566 0.184,1.031 0.551,1.387 0.371,0.351 0.808,0.531 1.312,0.531 0.535,0 0.985,-0.18 1.34,-0.535 0.234,-0.235 0.367,-0.559 0.402,-0.973 l -1.742,0 0,-0.578 2.324,0 c 0.028,0.125 0.036,0.246 0.036,0.363 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" + id="path20" + inkscape:connector-curvature="0" /></g><g + id="g22" + transform="matrix(1,0,0,-1,0,48)"><path + d="m 49.621,14.191 -2.183,0 0,1.52 1.968,0 0,0.578 -1.968,0 0,1.52 2.183,0 0,0.589 -2.801,0 0,-4.796 2.801,0 0,0.589 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" + id="path24" + inkscape:connector-curvature="0" /></g><g + id="g26" + transform="matrix(1,0,0,-1,0,48)"><path + d="m 52.223,18.398 -0.618,0 0,-4.207 -1.339,0 0,-0.589 3.297,0 0,0.589 -1.34,0 0,4.207 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" + id="path28" + inkscape:connector-curvature="0" /></g><g + id="g30" + transform="matrix(1,0,0,-1,0,48)"><path + d="m 55.949,18.398 0,-4.796 0.617,0 0,4.796 -0.617,0 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" + id="path32" + inkscape:connector-curvature="0" /></g><g + id="g34" + transform="matrix(1,0,0,-1,0,48)"><path + d="m 59.301,18.398 -0.613,0 0,-4.207 -1.344,0 0,-0.589 3.301,0 0,0.589 -1.344,0 0,4.207 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" + id="path36" + inkscape:connector-curvature="0" /></g><g + id="g38" + transform="matrix(1,0,0,-1,0,48)"><path + d="m 66.887,17.781 c -0.473,0.485 -1.059,0.727 -1.758,0.727 -0.703,0 -1.289,-0.242 -1.762,-0.727 C 62.895,17.297 62.66,16.703 62.66,16 c 0,-0.703 0.235,-1.297 0.707,-1.781 0.473,-0.485 1.059,-0.727 1.762,-0.727 0.695,0 1.281,0.242 1.754,0.731 0.476,0.488 0.711,1.078 0.711,1.777 0,0.703 -0.235,1.297 -0.707,1.781 z m -3.063,-0.402 c 0.356,0.359 0.789,0.539 1.305,0.539 0.512,0 0.949,-0.18 1.301,-0.539 0.355,-0.359 0.535,-0.82 0.535,-1.379 0,-0.559 -0.18,-1.02 -0.535,-1.379 -0.352,-0.359 -0.789,-0.539 -1.301,-0.539 -0.516,0 -0.949,0.18 -1.305,0.539 -0.355,0.359 -0.535,0.82 -0.535,1.379 0,0.559 0.18,1.02 0.535,1.379 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" + id="path40" + inkscape:connector-curvature="0" /></g><g + id="g42" + transform="matrix(1,0,0,-1,0,48)"><path + d="m 68.461,18.398 0,-4.796 0.75,0 2.332,3.73 0.027,0 -0.027,-0.922 0,-2.808 0.617,0 0,4.796 -0.644,0 -2.442,-3.914 -0.027,0 0.027,0.926 0,2.988 -0.613,0 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" + id="path44" + inkscape:connector-curvature="0" /></g><path + d="m 62.508,22.598 c -1.879,0 -3.414,-1.43 -3.414,-3.403 0,-1.957 1.535,-3.402 3.414,-3.402 1.883,0 3.418,1.445 3.418,3.402 0,1.973 -1.535,3.403 -3.418,3.403 z m 0,-5.465 c -1.031,0 -1.918,0.851 -1.918,2.062 0,1.227 0.887,2.063 1.918,2.063 1.031,0 1.922,-0.836 1.922,-2.063 0,-1.211 -0.891,-2.062 -1.922,-2.062 z m -7.449,5.465 c -1.883,0 -3.414,-1.43 -3.414,-3.403 0,-1.957 1.531,-3.402 3.414,-3.402 1.882,0 3.414,1.445 3.414,3.402 0,1.973 -1.532,3.403 -3.414,3.403 z m 0,-5.465 c -1.032,0 -1.922,0.851 -1.922,2.062 0,1.227 0.89,2.063 1.922,2.063 1.031,0 1.918,-0.836 1.918,-2.063 0,-1.211 -0.887,-2.062 -1.918,-2.062 z m -8.864,4.422 0,-1.446 3.453,0 c -0.101,-0.808 -0.371,-1.402 -0.785,-1.816 -0.504,-0.5 -1.289,-1.055 -2.668,-1.055 -2.125,0 -3.789,1.715 -3.789,3.84 0,2.125 1.664,3.84 3.789,3.84 1.149,0 1.985,-0.449 2.602,-1.031 l 1.019,1.019 c -0.863,0.824 -2.011,1.457 -3.621,1.457 -2.914,0 -5.363,-2.371 -5.363,-5.285 0,-2.914 2.449,-5.285 5.363,-5.285 1.575,0 2.758,0.516 3.688,1.484 0.953,0.953 1.25,2.293 1.25,3.375 0,0.336 -0.028,0.645 -0.078,0.903 l -4.86,0 z m 36.246,-1.121 c -0.281,0.761 -1.148,2.164 -2.914,2.164 -1.75,0 -3.207,-1.379 -3.207,-3.403 0,-1.906 1.442,-3.402 3.375,-3.402 1.563,0 2.465,0.953 2.836,1.508 l -1.16,0.773 c -0.387,-0.566 -0.914,-0.941 -1.676,-0.941 -0.757,0 -1.3,0.347 -1.648,1.031 l 4.551,1.883 -0.157,0.387 z m -4.64,-1.133 c -0.039,1.312 1.019,1.984 1.777,1.984 0.594,0 1.098,-0.297 1.266,-0.722 L 77.801,19.301 Z M 74.102,16 l 1.496,0 0,10 -1.496,0 0,-10 z m -2.45,5.84 -0.05,0 c -0.336,0.398 -0.977,0.758 -1.789,0.758 -1.704,0 -3.262,-1.496 -3.262,-3.414 0,-1.907 1.558,-3.391 3.262,-3.391 0.812,0 1.453,0.363 1.789,0.773 l 0.05,0 0,-0.488 c 0,-1.301 -0.695,-2 -1.816,-2 -0.914,0 -1.481,0.66 -1.715,1.215 L 66.82,14.75 c 0.375,-0.902 1.368,-2.012 3.016,-2.012 1.754,0 3.234,1.032 3.234,3.543 l 0,6.11 -1.418,0 0,-0.551 z m -1.711,-4.707 c -1.031,0 -1.894,0.863 -1.894,2.051 0,1.199 0.863,2.074 1.894,2.074 1.016,0 1.817,-0.875 1.817,-2.074 0,-1.188 -0.801,-2.051 -1.817,-2.051 z M 89.445,26 l -3.578,0 0,-10 1.492,0 0,3.789 2.086,0 c 1.657,0 3.282,1.199 3.282,3.106 0,1.906 -1.629,3.105 -3.282,3.105 z m 0.039,-4.82 -2.125,0 0,3.429 2.125,0 c 1.114,0 1.75,-0.925 1.75,-1.714 0,-0.774 -0.636,-1.715 -1.75,-1.715 z m 9.223,1.437 c -1.078,0 -2.199,-0.476 -2.66,-1.531 l 1.324,-0.555 c 0.285,0.555 0.809,0.735 1.363,0.735 0.774,0 1.559,-0.465 1.571,-1.286 l 0,-0.105 c -0.27,0.156 -0.848,0.387 -1.559,0.387 -1.426,0 -2.879,-0.785 -2.879,-2.25 0,-1.34 1.168,-2.203 2.481,-2.203 1.004,0 1.558,0.453 1.906,0.98 l 0.051,0 0,-0.773 1.441,0 0,3.836 c 0,1.773 -1.324,2.765 -3.039,2.765 z m -0.18,-5.48 c -0.488,0 -1.168,0.242 -1.168,0.847 0,0.774 0.848,1.071 1.582,1.071 0.657,0 0.965,-0.145 1.364,-0.336 -0.117,-0.926 -0.914,-1.582 -1.778,-1.582 z m 8.469,5.261 -1.715,-4.335 -0.051,0 -1.773,4.335 -1.609,0 2.664,-6.058 -1.52,-3.371 1.559,0 4.105,9.429 -1.66,0 z M 93.547,16 l 1.496,0 0,10 -1.496,0 0,-10 z" + style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path46" + inkscape:connector-curvature="0" /><g + id="g48"><path + d="M 16.348,33.969 C 16.113,33.723 15.977,33.34 15.977,32.844 l 0,-17.692 c 0,-0.496 0.136,-0.879 0.371,-1.125 l 0.058,-0.054 9.914,9.91 0,0.234 -9.914,9.91 -0.058,-0.058 z" + style="fill:url(#linearGradient50);fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path64" + inkscape:connector-curvature="0" /></g><g + id="g66"><path + d="m 29.621,20.578 -3.301,3.305 0,0.234 3.305,3.305 0.074,-0.043 3.914,-2.227 c 1.117,-0.632 1.117,-1.672 0,-2.308 l -3.914,-2.223 -0.078,-0.043 z" + style="fill:url(#linearGradient68);fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path78" + inkscape:connector-curvature="0" /></g><g + id="g80"><path + d="M 29.699,20.621 26.32,24 16.348,14.027 c 0.371,-0.39 0.976,-0.437 1.664,-0.047 l 11.687,6.641" + style="fill:url(#linearGradient82);fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path88" + inkscape:connector-curvature="0" /></g><g + id="g90"><path + d="M 29.699,27.379 18.012,34.02 c -0.688,0.386 -1.293,0.339 -1.664,-0.051 L 26.32,24 l 3.379,3.379 z" + style="fill:url(#linearGradient92);fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path104" + inkscape:connector-curvature="0" /></g><g + id="g106"><g + id="g108" /><g + id="g122" + mask="url(#mask114)"><g + id="g124" /><g + id="g146"><g + clip-path="url(#clipPath126)" + id="g148"><g + id="g150"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + style="fill:url(#pattern134);fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path152" + inkscape:connector-curvature="0" /></g></g></g></g></g><g + id="g154"><g + id="g156" /><g + id="g170" + mask="url(#mask162)"><g + id="g172" /><g + id="g194"><g + clip-path="url(#clipPath174)" + id="g196"><g + id="g198"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + style="fill:url(#pattern182);fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path200" + inkscape:connector-curvature="0" /></g></g></g></g></g><g + id="g202"><g + id="g204" /><g + id="g218" + mask="url(#mask210)"><g + id="g220" /><g + id="g242"><g + clip-path="url(#clipPath222)" + id="g244"><g + id="g246"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + style="fill:url(#pattern230);fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path248" + inkscape:connector-curvature="0" /></g></g></g></g></g><g + id="g250"><g + id="g252" /><g + id="g266" + mask="url(#mask258)"><g + id="g268" /><g + id="g290"><g + clip-path="url(#clipPath270)" + id="g292"><g + id="g294"><path + d="M 0,0 124,0 124,48 0,48 0,0 Z" + style="fill:url(#pattern278);fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path296" + inkscape:connector-curvature="0" /></g></g></g></g></g></g></g></svg> \ No newline at end of file diff --git a/themes/triangles/client/src/img/success.png b/themes/triangles/client/src/img/success.png new file mode 100644 index 0000000000000000000000000000000000000000..ee9d6841bbb7208220a831c1c42917eb8facffa5 GIT binary patch literal 3147 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 literal 0 HcmV?d00001 diff --git a/themes/triangles/client/src/img/user.png b/themes/triangles/client/src/img/user.png new file mode 100644 index 0000000000000000000000000000000000000000..00941399d34f0f2d85d323f61daa67c2c00c494d GIT binary patch literal 2933 zcmV-*3ySoKP)<h;3K|Lk000e1NJLTq004jh004jp1^@s6!#-il00004b3#c}2nYxW zd<bNS00009a7bBm001Fv001Fv0p1w_H2?qr8FWQhbW?9;ba!ELWdL_~cP?peYja~^ zaAhuUa%Y?FJQ@H13i3%rK~#9!?VW3E6xS8Uoe(0&NRTEGX$vK7KJ-JS(q|%(5|oIl zZTh7W5*PvrwH*osA%uccibe3Q7elFSi~s{Eudpr%n@1oJz{TvYpNqkc->>+Y_1buC zheF*1yf*GR)m>@otnGcw%sqGZe)vfU+q>t^|F^R<_ujcTYIJlo%4jV9WAaR39<TyP z2CP6KPzf{woj@;e9~c5206joGPz+pRf0GHU1zrWdWyE7;gs`Up3t7-NSg4~K(FI%u z(tt(43@L#Afj<ts#zMFWcr^@sGVr~NfUUsGSeq0;yWn2}G5|Nnz$aq>I1IcZ1yCpS 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<Q)=&@M2iD5@EPzw)qSX{2k!OS6TxI!O zl<<9E&737jQbhoqw#;U(a%;@KBj+8W34k?o765NcXQ?3o?<aLeTEB4L6-@xFnZN|t z?63sDr&oCQaxHr&rA@T(ePHdJk00KW&I?BX?lXJd-IQ4(ssLC!fo@`FNCHs!^dqUe zvu&aZkh=RHHX@^juZJQ4iFtkLSvkTAkhcG{gNOiEf)aqrrytpUFgIUR0YKhDD#sDm zTnI*h^Hd7YJap!|zyeTu8p;}w0Jx+HrIk5E79fL`0A7EOo004FlLF{Fam1eh4N?HZ z3eYm40JyvvwJFe20M&VEA^{Fd0SqrdhF<~LEZ+esfZ+vjE1x!23h*+;r(mQ2n(&%G z0k%p3B1V8Te*zRq0U}0#oBt;OE_+UOSym~4W}QOQlmaZG`XsCrKr<F91xTY>=$=Do z#TDQ{E@c6dl>%I)Smctp<h!*^ZJnYDP}R`thBn+ID!>h;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%<ZuzN@#-`_`J&T0!<7I)nlwQ!f-wqg|p1 zkaNN26eN5k6$Mx-La~Kq)%79>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&<DjUA>+Myu$D9>mOOMdV9Tq;UhoxC%|Js zpGXZc?K;kFCGqsE9J`?5w@<WC<Z;0oGwg6yRq7pnp5qp7p*Jk30DC7AV7_P#T9%N~ z0-=oPwi%SG`(I1qOddhQM}9roV$pS?H41{!?OLE5?m-0(zg@XX=y(EbkOCN1fW$zS zi+=|AS_)t|0dVurX9C?a#wrCcoB&6J+%!rGpc+35cH6iDDS%-FC<<lYz*nRIh7sV6 zP`8f#ScGu_2*#_I02Q^(?Sh4$7tY?nKce2_6&tVbT)~d+JA(p+kGq%84R`zaqm&z& zuzr{B3rXOEt0G_d2P?q$DEAATI+w5e452M4e-~jO2+vgC30nZl+sQ0VNNII@`X1^( z|Nrw20S1G%1(I!`0!#s@EpswhR@u<ZYdART=miJi@NuF_v}yt{34Vx=Ym@lo$D3XD z(#jg%GlF*RIcZ}TTBK%913w8g*MJ3J60GBW%ZDp(x4F9SQJE8%UsO@G?41-!eFK|< z$uw92aMsW@9t~UbUPdY2gHwDc#O>)Hw&vy)6A®tx`|Cc!K{R~B2jc1N|ng#YPY z01+^g*}>)+v;a(kg*ZQ7)0sYS5YJw62)S4!2N7^)@}f|(3`qbcL4r0*!k9b<Kf36E z+dHp_;d2t<{ZMlZSpX)%4mEG<wr6D9I^1^#jj}wPP9kK5lVP|5;6-6hxHEe<eQ=<l zvF(dqqXn;n2p6%BhMNFPf@q*Llm@KcnC=vD1u!Ba;N$2q;pP{%08E1Cfbu|#e``%@ z4c<RTTJS20(CmA-lY{_@4XBGITju5T1zTyP2Op*gpQ#nTssb<xV*GU!!Ik~M{-Yl| zx_bMDWZ(gbFy5pST-9}knwhC80F&TJTz<q?G%lOg(9+o}0}dQlujip9-HPK3UPbty z;(}gk=cT#;zR|<jc=NG6AD%7f?i+X{Lk*4MTeS_#S6Q0aYOYhY$Vw9d5NN<-z(zeM zY#NZE&h9&#$3D&_ga8T=<^nY`%%H&qFn*zRKAH=_B!~tw0r_<h82f;y^vFgR0Td$q z2I!Unj|(1~-|Cr(t^zO#o(0ayu)o0>JT`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}b2r<A0z$IYtoxpm%?&l3DfI@^PfQ9V4lWIT0 zFn~kdX5dw<S%hONgaBh?cpUf@kO9<j?p?$+3J(B_sGi~tCx9Ozo(A3kQh<Do`xd@r z9X6|}T*XJc0OO28&H@$yaexIl1>kn{6#(u#g!>KLW&ICB?B8ANXZSm~9Q{dlI$z1o fINvtfu|oa}IMcn@FFyTa00000NkvXXu0mjfV5=n> literal 0 HcmV?d00001 diff --git a/themes/triangles/client/src/img/warning.png b/themes/triangles/client/src/img/warning.png new file mode 100644 index 0000000000000000000000000000000000000000..c6acd953b142a8e43e293c353a60e6313a8d39eb GIT binary patch literal 4038 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 F{{xrIJYfI; literal 0 HcmV?d00001 diff --git a/themes/triangles/client/src/index.ts b/themes/triangles/client/src/index.ts new file mode 100644 index 00000000..802004a8 --- /dev/null +++ b/themes/triangles/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 () { + (<any>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/triangles/client/src/lib/GetPromised.ts b/themes/triangles/client/src/lib/GetPromised.ts new file mode 100644 index 00000000..77913965 --- /dev/null +++ b/themes/triangles/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<any> { + return new BluebirdPromise<any>((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/triangles/client/src/lib/INotifier.ts b/themes/triangles/client/src/lib/INotifier.ts new file mode 100644 index 00000000..df947538 --- /dev/null +++ b/themes/triangles/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/triangles/client/src/lib/Notifier.ts b/themes/triangles/client/src/lib/Notifier.ts new file mode 100644 index 00000000..c0252b9b --- /dev/null +++ b/themes/triangles/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('<i><img src="/img/notifications/%s.png" alt="status %s"/></i>\ + <span>%s</span>', 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/triangles/client/src/lib/QueryParametersRetriever.ts b/themes/triangles/client/src/lib/QueryParametersRetriever.ts new file mode 100644 index 00000000..a529adb6 --- /dev/null +++ b/themes/triangles/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/triangles/client/src/lib/SafeRedirect.ts b/themes/triangles/client/src/lib/SafeRedirect.ts new file mode 100644 index 00000000..7e7684b8 --- /dev/null +++ b/themes/triangles/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/triangles/client/src/lib/firstfactor/FirstFactorValidator.ts b/themes/triangles/client/src/lib/firstfactor/FirstFactorValidator.ts new file mode 100644 index 00000000..eaa496fd --- /dev/null +++ b/themes/triangles/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<string> { + return new BluebirdPromise<string>(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/triangles/client/src/lib/firstfactor/UISelectors.ts b/themes/triangles/client/src/lib/firstfactor/UISelectors.ts new file mode 100644 index 00000000..0e971b3c --- /dev/null +++ b/themes/triangles/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/triangles/client/src/lib/firstfactor/index.ts b/themes/triangles/client/src/lib/firstfactor/index.ts new file mode 100644 index 00000000..24affee2 --- /dev/null +++ b/themes/triangles/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/triangles/client/src/lib/reset-password/constants.ts b/themes/triangles/client/src/lib/reset-password/constants.ts new file mode 100644 index 00000000..d48d4e67 --- /dev/null +++ b/themes/triangles/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/triangles/client/src/lib/reset-password/reset-password-form.ts b/themes/triangles/client/src/lib/reset-password/reset-password-form.ts new file mode 100644 index 00000000..b94279cd --- /dev/null +++ b/themes/triangles/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/triangles/client/src/lib/reset-password/reset-password-request.ts b/themes/triangles/client/src/lib/reset-password/reset-password-request.ts new file mode 100644 index 00000000..846226d7 --- /dev/null +++ b/themes/triangles/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/triangles/client/src/lib/secondfactor/TOTPValidator.ts b/themes/triangles/client/src/lib/secondfactor/TOTPValidator.ts new file mode 100644 index 00000000..5394139a --- /dev/null +++ b/themes/triangles/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<string> { + return new BluebirdPromise<string>(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/triangles/client/src/lib/secondfactor/U2FValidator.ts b/themes/triangles/client/src/lib/secondfactor/U2FValidator.ts new file mode 100644 index 00000000..5812922f --- /dev/null +++ b/themes/triangles/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<string> { + return new BluebirdPromise<string>(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<string> { + 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/triangles/client/src/lib/secondfactor/constants.ts b/themes/triangles/client/src/lib/secondfactor/constants.ts new file mode 100644 index 00000000..50bba757 --- /dev/null +++ b/themes/triangles/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/triangles/client/src/lib/secondfactor/index.ts b/themes/triangles/client/src/lib/secondfactor/index.ts new file mode 100644 index 00000000..279723dc --- /dev/null +++ b/themes/triangles/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/triangles/client/src/lib/totp-register/totp-register.ts b/themes/triangles/client/src/lib/totp-register/totp-register.ts new file mode 100644 index 00000000..6a9aa7ee --- /dev/null +++ b/themes/triangles/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/triangles/client/src/lib/totp-register/ui-selector.ts b/themes/triangles/client/src/lib/totp-register/ui-selector.ts new file mode 100644 index 00000000..9d43fabe --- /dev/null +++ b/themes/triangles/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/triangles/client/src/lib/u2f-register/u2f-register.ts b/themes/triangles/client/src/lib/u2f-register/u2f-register.ts new file mode 100644 index 00000000..abf40ee0 --- /dev/null +++ b/themes/triangles/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<string> { + return new BluebirdPromise<string>(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<string> { + 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/triangles/client/src/thirdparties/qrcode.min.js b/themes/triangles/client/src/thirdparties/qrcode.min.js new file mode 100644 index 00000000..993e88f3 --- /dev/null +++ b/themes/triangles/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<a.length&&0==a[c];)c++;this.num=new Array(a.length-c+b);for(var d=0;d<a.length-c;d++)this.num[d]=a[d+c]}function j(a,b){this.totalCount=a,this.dataCount=b}function k(){this.buffer=[],this.length=0}function m(){return"undefined"!=typeof CanvasRenderingContext2D}function n(){var a=!1,b=navigator.userAgent;return/android/i.test(b)&&(a=!0,aMat=b.toString().match(/android ([0-9]\.[0-9])/i),aMat&&aMat[1]&&(a=parseFloat(aMat[1]))),a}function r(a,b){for(var c=1,e=s(a),f=0,g=l.length;g>=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<this.moduleCount;d++){this.modules[d]=new Array(this.moduleCount);for(var e=0;e<this.moduleCount;e++)this.modules[d][e]=null}this.setupPositionProbePattern(0,0),this.setupPositionProbePattern(this.moduleCount-7,0),this.setupPositionProbePattern(0,this.moduleCount-7),this.setupPositionAdjustPattern(),this.setupTimingPattern(),this.setupTypeInfo(a,c),this.typeNumber>=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<this.modules.length;f++)for(var g=f*e,h=0;h<this.modules[f].length;h++){var i=h*e,j=this.modules[f][h];j&&(d.beginFill(0,100),d.moveTo(i,g),d.lineTo(i+e,g),d.lineTo(i+e,g+e),d.lineTo(i,g+e),d.endFill())}return d},setupTimingPattern:function(){for(var a=8;a<this.moduleCount-8;a++)null==this.modules[a][6]&&(this.modules[a][6]=0==a%2);for(var b=8;b<this.moduleCount-8;b++)null==this.modules[6][b]&&(this.modules[6][b]=0==b%2)},setupPositionAdjustPattern:function(){for(var a=f.getPatternPosition(this.typeNumber),b=0;b<a.length;b++)for(var c=0;c<a.length;c++){var d=a[b],e=a[c];if(null==this.modules[d][e])for(var g=-2;2>=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<a.length&&(j=1==(1&a[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;h<d.length;h++){var i=d[h];g.put(i.mode,4),g.put(i.getLength(),f.getLengthInBits(i.mode,a)),i.write(g)}for(var l=0,h=0;h<e.length;h++)l+=e[h].dataCount;if(g.getLengthInBits()>8*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<b.length;j++){var k=b[j].dataCount,l=b[j].totalCount-k;d=Math.max(d,k),e=Math.max(e,l),g[j]=new Array(k);for(var m=0;m<g[j].length;m++)g[j][m]=255&a.buffer[m+c];c+=k;var n=f.getErrorCorrectPolynomial(l),o=new i(g[j],n.getLength()-1),p=o.mod(n);h[j]=new Array(n.getLength()-1);for(var m=0;m<h[j].length;m++){var q=m+p.getLength()-h[j].length;h[j][m]=q>=0?p.get(q):0}}for(var r=0,m=0;m<b.length;m++)r+=b[m].totalCount;for(var s=new Array(r),t=0,m=0;d>m;m++)for(var j=0;j<b.length;j++)m<g[j].length&&(s[t++]=g[j][m]);for(var m=0;e>m;m++)for(var j=0;j<b.length;j++)m<h[j].length&&(s[t++]=h[j][m]);return s};for(var c={MODE_NUMBER:1,MODE_ALPHA_NUM:2,MODE_8BIT_BYTE:4,MODE_KANJI:8},d={L:1,M:0,Q:3,H:2},e={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7},f={PATTERN_POSITION_TABLE:[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],G15:1335,G18:7973,G15_MASK:21522,getBCHTypeInfo:function(a){for(var b=a<<10;f.getBCHDigit(b)-f.getBCHDigit(f.G15)>=0;)b^=f.G15<<f.getBCHDigit(b)-f.getBCHDigit(f.G15);return(a<<10|b)^f.G15_MASK},getBCHTypeNumber:function(a){for(var b=a<<12;f.getBCHDigit(b)-f.getBCHDigit(f.G18)>=0;)b^=f.G18<<f.getBCHDigit(b)-f.getBCHDigit(f.G18);return a<<12|b},getBCHDigit:function(a){for(var b=0;0!=a;)b++,a>>>=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;for(var h=8;256>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;c<this.getLength();c++)for(var d=0;d<a.getLength();d++)b[c+d]^=g.gexp(g.glog(this.get(c))+g.glog(a.get(d)));return new i(b,0)},mod:function(a){if(this.getLength()-a.getLength()<0)return this;for(var b=g.glog(this.get(0))-g.glog(a.get(0)),c=new Array(this.getLength()),d=0;d<this.getLength();d++)c[d]=this.get(d);for(var d=0;d<a.getLength();d++)c[d]^=g.gexp(g.glog(a.get(d))+b);return new i(c,0).mod(a)}},j.RS_BLOCK_TABLE=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]],j.getRSBlocks=function(a,b){var c=j.getRsBlockTable(a,b);if(void 0==c)throw new Error("bad rs block @ typeNumber:"+a+"/errorCorrectLevel:"+b);for(var d=c.length/3,e=[],f=0;d>f;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=['<table style="border:0;border-collapse:collapse;">'],h=0;d>h;h++){g.push("<tr>");for(var i=0;d>i;i++)g.push('<td style="border:0;border-collapse:collapse;padding:0;margin:0;width:'+e+"px;height:"+f+"px;background-color:"+(a.isDark(h,i)?b.colorDark:b.colorLight)+';"></td>');g.push("</tr>")}g.push("</table>"),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/triangles/client/src/thirdparties/u2f-api.js b/themes/triangles/client/src/thirdparties/u2f-api.js new file mode 100644 index 00000000..8c7801e3 --- /dev/null +++ b/themes/triangles/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.Transport>} + */ +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<u2f.SignRequest>} 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<u2f.SignRequest>} signRequests + * @param {Array<u2f.RegisterRequest>} 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<function((MessagePort|u2f.WrappedChromeRuntimePort_))>} + * @private + */ +u2f.waitingForPort_ = []; + +/** + * A counter for requestIds. + * @type {number} + * @private + */ +u2f.reqCounter_ = 0; + +/** + * A map from requestIds to client callbacks + * @type {Object.<number,(function((u2f.Error|u2f.RegisterResponse)) + * |function((u2f.Error|u2f.SignResponse)))>} + * @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.<u2f.Response>} 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<u2f.RegisteredKey>} 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<u2f.RegisteredKey>} 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<u2f.RegisterRequest>} registerRequests + * @param {Array<u2f.RegisteredKey>} 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<u2f.RegisterRequest>} registerRequests + * @param {Array<u2f.RegisteredKey>} 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/triangles/client/test/Notifier.test.ts b/themes/triangles/client/test/Notifier.test.ts new file mode 100644 index 00000000..70bfea14 --- /dev/null +++ b/themes/triangles/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/triangles/client/test/firstfactor/FirstFactorValidator.test.ts b/themes/triangles/client/test/firstfactor/FirstFactorValidator.test.ts new file mode 100644 index 00000000..ac835327 --- /dev/null +++ b/themes/triangles/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/triangles/client/test/mocks/NotifierStub.ts b/themes/triangles/client/test/mocks/NotifierStub.ts new file mode 100644 index 00000000..9c268d66 --- /dev/null +++ b/themes/triangles/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/triangles/client/test/mocks/jquery.ts b/themes/triangles/client/test/mocks/jquery.ts new file mode 100644 index 00000000..273f9086 --- /dev/null +++ b/themes/triangles/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/triangles/client/test/mocks/u2f-api.ts b/themes/triangles/client/test/mocks/u2f-api.ts new file mode 100644 index 00000000..d123f6a9 --- /dev/null +++ b/themes/triangles/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/triangles/client/test/secondfactor/TOTPValidator.test.ts b/themes/triangles/client/test/secondfactor/TOTPValidator.test.ts new file mode 100644 index 00000000..5dd6f15c --- /dev/null +++ b/themes/triangles/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/triangles/client/test/totp-register/totp-register.test.ts b/themes/triangles/client/test/totp-register/totp-register.test.ts new file mode 100644 index 00000000..86fc455a --- /dev/null +++ b/themes/triangles/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/triangles/client/tsconfig.json b/themes/triangles/client/tsconfig.json new file mode 100644 index 00000000..0bb4d62f --- /dev/null +++ b/themes/triangles/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/triangles/client/tslint.json b/themes/triangles/client/tslint.json new file mode 100644 index 00000000..c2c1b750 --- /dev/null +++ b/themes/triangles/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/triangles/server/.directory b/themes/triangles/server/.directory new file mode 100644 index 00000000..b7754766 --- /dev/null +++ b/themes/triangles/server/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,17,20,58,20 +Version=3 +ViewMode=1 diff --git a/themes/triangles/server/src/index.ts b/themes/triangles/server/src/index.ts new file mode 100755 index 00000000..fcbf4d02 --- /dev/null +++ b/themes/triangles/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 <config>"); + 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/triangles/server/src/lib/.directory b/themes/triangles/server/src/lib/.directory new file mode 100644 index 00000000..006b379a --- /dev/null +++ b/themes/triangles/server/src/lib/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,17,20,59,13 +Version=3 +ViewMode=1 diff --git a/themes/triangles/server/src/lib/AuthenticationSessionHandler.ts b/themes/triangles/server/src/lib/AuthenticationSessionHandler.ts new file mode 100644 index 00000000..57361bf8 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/ErrorReplies.ts b/themes/triangles/server/src/lib/ErrorReplies.ts new file mode 100644 index 00000000..f1c5f4fd --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/Exceptions.ts b/themes/triangles/server/src/lib/Exceptions.ts new file mode 100644 index 00000000..83fa4eb6 --- /dev/null +++ b/themes/triangles/server/src/lib/Exceptions.ts @@ -0,0 +1,88 @@ + +export class LdapSearchError extends Error { + constructor(message?: string) { + super(message); + this.name = "LdapSearchError"; + (<any>Object).setPrototypeOf(this, LdapSearchError.prototype); + } +} + +export class LdapBindError extends Error { + constructor(message?: string) { + super(message); + this.name = "LdapBindError"; + (<any>Object).setPrototypeOf(this, LdapBindError.prototype); + } +} + +export class LdapError extends Error { + constructor(message?: string) { + super(message); + this.name = "LdapError"; + (<any>Object).setPrototypeOf(this, LdapError.prototype); + } +} + +export class IdentityError extends Error { + constructor(message?: string) { + super(message); + this.name = "IdentityError"; + (<any>Object).setPrototypeOf(this, IdentityError.prototype); + } +} + +export class AccessDeniedError extends Error { + constructor(message?: string) { + super(message); + this.name = "AccessDeniedError"; + (<any>Object).setPrototypeOf(this, AccessDeniedError.prototype); + } +} + +export class AuthenticationRegulationError extends Error { + constructor(message?: string) { + super(message); + this.name = "AuthenticationRegulationError"; + (<any>Object).setPrototypeOf(this, AuthenticationRegulationError.prototype); + } +} + +export class InvalidTOTPError extends Error { + constructor(message?: string) { + super(message); + this.name = "InvalidTOTPError"; + (<any>Object).setPrototypeOf(this, InvalidTOTPError.prototype); + } +} + +export class NotAuthenticatedError extends Error { + constructor(message?: string) { + super(message); + this.name = "NotAuthenticatedError"; + (<any>Object).setPrototypeOf(this, NotAuthenticatedError.prototype); + } +} + +export class NotAuthorizedError extends Error { + constructor(message?: string) { + super(message); + this.name = "NotAuthanticatedError"; + (<any>Object).setPrototypeOf(this, NotAuthorizedError.prototype); + } +} + +export class FirstFactorValidationError extends Error { + constructor(message?: string) { + super(message); + this.name = "FirstFactorValidationError"; + (<any>Object).setPrototypeOf(this, FirstFactorValidationError.prototype); + } +} + +export class SecondFactorValidationError extends Error { + constructor(message?: string) { + super(message); + this.name = "SecondFactorValidationError"; + (<any>Object).setPrototypeOf(this, FirstFactorValidationError.prototype); + } +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/FirstFactorValidator.ts b/themes/triangles/server/src/lib/FirstFactorValidator.ts new file mode 100644 index 00000000..23106000 --- /dev/null +++ b/themes/triangles/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<void> { + 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/triangles/server/src/lib/IdentityCheckMiddleware.spec.ts b/themes/triangles/server/src/lib/IdentityCheckMiddleware.spec.ts new file mode 100644 index 00000000..842ed6bc --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/IdentityCheckMiddleware.ts b/themes/triangles/server/src/lib/IdentityCheckMiddleware.ts new file mode 100644 index 00000000..e72ea4db --- /dev/null +++ b/themes/triangles/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<string> { + + 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<IdentityValidationDocument> { + 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<void> { + 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<void> { + + let authSession: AuthenticationSession; + const identityToken = objectPath.get<Express.Request, string>( + 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<void> { + 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/triangles/server/src/lib/IdentityCheckPreValidationTemplate.ts b/themes/triangles/server/src/lib/IdentityCheckPreValidationTemplate.ts new file mode 100644 index 00000000..0161ce40 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/IdentityValidable.ts b/themes/triangles/server/src/lib/IdentityValidable.ts new file mode 100644 index 00000000..075580c9 --- /dev/null +++ b/themes/triangles/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<Identity.Identity>; + postValidationInit(req: Express.Request): Bluebird<void>; + + // 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/triangles/server/src/lib/IdentityValidableStub.spec.ts b/themes/triangles/server/src/lib/IdentityValidableStub.spec.ts new file mode 100644 index 00000000..20a97714 --- /dev/null +++ b/themes/triangles/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<Identity> { + return this.preValidationInitStub(req); + } + + postValidationInit(req: Express.Request): Bluebird<void> { + 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/triangles/server/src/lib/Server.spec.ts b/themes/triangles/server/src/lib/Server.spec.ts new file mode 100644 index 00000000..36516325 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/Server.ts b/themes/triangles/server/src/lib/Server.ts new file mode 100644 index 00000000..4090f629 --- /dev/null +++ b/themes/triangles/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<void> { + 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<void>((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<void> { + 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/triangles/server/src/lib/ServerVariables.ts b/themes/triangles/server/src/lib/ServerVariables.ts new file mode 100644 index 00000000..cd3dd6dc --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/ServerVariablesInitializer.ts b/themes/triangles/server/src/lib/ServerVariablesInitializer.ts new file mode 100644 index 00000000..df79238c --- /dev/null +++ b/themes/triangles/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<UserDataStore> { + 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<ServerVariables> { + + 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/triangles/server/src/lib/ServerVariablesMockBuilder.spec.ts b/themes/triangles/server/src/lib/ServerVariablesMockBuilder.spec.ts new file mode 100644 index 00000000..7874702a --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/Level.ts b/themes/triangles/server/src/lib/authentication/Level.ts new file mode 100644 index 00000000..57b6a234 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/backends/GroupsAndEmails.ts b/themes/triangles/server/src/lib/authentication/backends/GroupsAndEmails.ts new file mode 100644 index 00000000..3434ba66 --- /dev/null +++ b/themes/triangles/server/src/lib/authentication/backends/GroupsAndEmails.ts @@ -0,0 +1,5 @@ + +export interface GroupsAndEmails { + groups: string[]; + emails: string[]; +} diff --git a/themes/triangles/server/src/lib/authentication/backends/IUsersDatabase.ts b/themes/triangles/server/src/lib/authentication/backends/IUsersDatabase.ts new file mode 100644 index 00000000..d7fa13b7 --- /dev/null +++ b/themes/triangles/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<GroupsAndEmails>; + getEmails(username: string): Bluebird<string[]>; + getGroups(username: string): Bluebird<string[]>; + updatePassword(username: string, newPassword: string): Bluebird<void>; +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/authentication/backends/IUsersDatabaseStub.spec.ts b/themes/triangles/server/src/lib/authentication/backends/IUsersDatabaseStub.spec.ts new file mode 100644 index 00000000..19341a5d --- /dev/null +++ b/themes/triangles/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<GroupsAndEmails> { + return this.checkUserPasswordStub(username, password); + } + + getEmails(username: string): Bluebird<string[]> { + return this.getEmailsStub(username); + } + + getGroups(username: string): Bluebird<string[]> { + return this.getGroupsStub(username); + } + + updatePassword(username: string, newPassword: string): Bluebird<void> { + return this.updatePasswordStub(username, newPassword); + } +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/authentication/backends/file/FileUsersDatabase.spec.ts b/themes/triangles/server/src/lib/authentication/backends/file/FileUsersDatabase.spec.ts new file mode 100644 index 00000000..a258a78f --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/backends/file/FileUsersDatabase.ts b/themes/triangles/server/src/lib/authentication/backends/file/FileUsersDatabase.ts new file mode 100644 index 00000000..d34dde21 --- /dev/null +++ b/themes/triangles/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<any> { + return new Bluebird<string>((resolve, reject) => { + this.queue.read((err: Error, data: string) => { + if (err) { + reject(err); + return; + } + resolve(data); + this.queue.next(); + }); + }) + .then((content) => { + const database = Yaml.parse(content); + if (!database) { + return Bluebird.reject(new Error("Unable to parse YAML file.")); + } + return Bluebird.resolve(database); + }); + } + + /** + * Checks the user exists in the database. + */ + private checkUserExists( + database: any, + username: string) + : Bluebird<void> { + if (!(username in database.users)) { + return Bluebird.reject( + new Error(`User ${username} does not exist in database.`)); + } + return Bluebird.resolve(); + } + + /** + * Check the password of a given user. + */ + private checkPassword( + database: any, + username: string, + password: string) + : Bluebird<void> { + const storedHash: string = database.users[username].password; + const matches = storedHash.match(/rounds=([0-9]+)\$([a-zA-z0-9]+)\$/); + if (!(matches && matches.length == 3)) { + return Bluebird.reject(new Error("Unable to detect the hash salt and rounds. " + + "Make sure the password is hashed with SSHA512.")); + } + + const rounds: number = parseInt(matches[1]); + const salt = matches[2]; + + return HashGenerator.ssha512(password, rounds, salt) + .then((hash: string) => { + if (hash !== storedHash) { + return Bluebird.reject(new Error("Wrong username/password.")); + } + return Bluebird.resolve(); + }); + } + + /** + * Retrieve email addresses of a given user. + */ + private retrieveEmails( + database: any, + username: string) + : Bluebird<string[]> { + if (!("email" in database.users[username])) { + return Bluebird.reject( + new Error(`User ${username} has no email address.`)); + } + return Bluebird.resolve( + [database.users[username].email]); + } + + private retrieveGroups( + database: any, + username: string) + : Bluebird<string[]> { + if (!("groups" in database.users[username])) { + return Bluebird.resolve([]); + } + return Bluebird.resolve( + database.users[username].groups); + } + + private replacePassword( + database: any, + username: string, + newPassword: string) + : Bluebird<void> { + const that = this; + return HashGenerator.ssha512(newPassword) + .then((hash) => { + database.users[username].password = hash; + const str = Yaml.stringify(database, 4, 2); + return Bluebird.resolve(str); + }) + .then((content: string) => { + return new Bluebird((resolve, reject) => { + that.queue.write(content, (err) => { + if (err) { + return reject(err); + } + resolve(); + that.queue.next(); + }); + }); + }); + } + + checkUserPassword( + username: string, + password: string) + : Bluebird<GroupsAndEmails> { + return this.readDatabase() + .then((database) => { + return this.checkUserExists(database, username) + .then(() => this.checkPassword(database, username, password)) + .then(() => { + return Bluebird.join( + this.retrieveEmails(database, username), + this.retrieveGroups(database, username) + ).spread((emails: string[], groups: string[]) => { + return { emails: emails, groups: groups }; + }); + }); + }); + } + + getEmails(username: string): Bluebird<string[]> { + return this.readDatabase() + .then((database) => { + return this.checkUserExists(database, username) + .then(() => this.retrieveEmails(database, username)); + }); + } + + getGroups(username: string): Bluebird<string[]> { + return this.readDatabase() + .then((database) => { + return this.checkUserExists(database, username) + .then(() => this.retrieveGroups(database, username)); + }); + } + + updatePassword(username: string, newPassword: string): Bluebird<void> { + return this.readDatabase() + .then((database) => { + return this.checkUserExists(database, username) + .then(() => this.replacePassword(database, username, newPassword)); + }); + } +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/authentication/backends/file/ReadWriteQueue.ts b/themes/triangles/server/src/lib/authentication/backends/file/ReadWriteQueue.ts new file mode 100644 index 00000000..957ddaec --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/backends/ldap/ISession.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/ISession.ts new file mode 100644 index 00000000..da2c7443 --- /dev/null +++ b/themes/triangles/server/src/lib/authentication/backends/ldap/ISession.ts @@ -0,0 +1,12 @@ + +import BluebirdPromise = require("bluebird"); + +export interface ISession { + open(): BluebirdPromise<void>; + close(): BluebirdPromise<void>; + + searchUserDn(username: string): BluebirdPromise<string>; + searchEmails(username: string): BluebirdPromise<string[]>; + searchGroups(username: string): BluebirdPromise<string[]>; + modifyPassword(username: string, newPassword: string): BluebirdPromise<void>; +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/authentication/backends/ldap/ISessionFactory.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/ISessionFactory.ts new file mode 100644 index 00000000..014d1eea --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.spec.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.spec.ts new file mode 100644 index 00000000..f4a6e630 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/LdapUsersDatabase.ts new file mode 100644 index 00000000..edda62ec --- /dev/null +++ b/themes/triangles/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<T> = (session: ISession) => Bluebird<T>; + +export class LdapUsersDatabase implements IUsersDatabase { + private sessionFactory: ISessionFactory; + private configuration: LdapConfiguration; + + constructor( + sessionFactory: ISessionFactory, + configuration: LdapConfiguration) { + this.sessionFactory = sessionFactory; + this.configuration = configuration; + } + + private withSession<T>( + username: string, + password: string, + cb: SessionCallback<T>): Bluebird<T> { + const session = this.sessionFactory.create(username, password); + return session.open() + .then(() => cb(session)) + .finally(() => session.close()); + } + + checkUserPassword(username: string, password: string): Bluebird<GroupsAndEmails> { + const that = this; + function verifyUserPassword(userDN: string) { + return that.withSession<void>( + 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<string[]> { + 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<string[]> { + 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<void> { + 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/triangles/server/src/lib/authentication/backends/ldap/SafeSession.spec.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/SafeSession.spec.ts new file mode 100644 index 00000000..9dedfcb7 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/backends/ldap/SafeSession.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/SafeSession.ts new file mode 100644 index 00000000..57220906 --- /dev/null +++ b/themes/triangles/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<void> { + return this.sesion.open(); + } + + close(): BluebirdPromise<void> { + return this.sesion.close(); + } + + searchGroups(username: string): BluebirdPromise<string[]> { + 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<string> { + 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<string[]> { + 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<void> { + 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/triangles/server/src/lib/authentication/backends/ldap/Sanitizer.spec.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/Sanitizer.spec.ts new file mode 100644 index 00000000..9dd33fed --- /dev/null +++ b/themes/triangles/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<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=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/triangles/server/src/lib/authentication/backends/ldap/Sanitizer.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/Sanitizer.ts new file mode 100644 index 00000000..be74132a --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/backends/ldap/Session.spec.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/Session.spec.ts new file mode 100644 index 00000000..d55f6a80 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/backends/ldap/Session.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/Session.ts new file mode 100644 index 00000000..e0284b3c --- /dev/null +++ b/themes/triangles/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<void> { + 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<void> { + 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<string> { + 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<string[]> { + 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<string> { + 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<string[]> { + 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<void> { + 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/triangles/server/src/lib/authentication/backends/ldap/SessionFactory.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/SessionFactory.ts new file mode 100644 index 00000000..0b6c4bff --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/backends/ldap/SessionFactoryStub.spec.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/SessionFactoryStub.spec.ts new file mode 100644 index 00000000..face3930 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/backends/ldap/SessionStub.spec.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/SessionStub.spec.ts new file mode 100644 index 00000000..5faf2ba1 --- /dev/null +++ b/themes/triangles/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<void> { + return this.openStub(); + } + + close(): Bluebird<void> { + return this.closeStub(); + } + + searchUserDn(username: string): Bluebird<string> { + return this.searchUserDnStub(username); + } + + searchEmails(username: string): Bluebird<string[]> { + return this.searchEmailsStub(username); + } + + searchGroups(username: string): Bluebird<string[]> { + return this.searchGroupsStub(username); + } + + modifyPassword(username: string, newPassword: string): Bluebird<void> { + return this.modifyPasswordStub(username, newPassword); + } +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/authentication/backends/ldap/connector/Connector.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/connector/Connector.ts new file mode 100644 index 00000000..2542ea7f --- /dev/null +++ b/themes/triangles/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<void>; + unbindAsync(): Bluebird<void>; + searchAsync(base: string, query: LdapJs.SearchOptions): Bluebird<EventEmitter>; + modifyAsync(userdn: string, change: LdapJs.Change): Bluebird<void>; +} + +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<void> { + return this.client.bindAsync(username, password); + } + + unbindAsync(): Bluebird<void> { + return this.client.unbindAsync(); + } + + searchAsync(base: string, query: any): Bluebird<any[]> { + const that = this; + return this.client.searchAsync(base, query) + .then(function (res: EventEmitter) { + const doc: SearchEntry[] = []; + return new Bluebird<any[]>((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<void> { + return this.client.modifyAsync(dn, changeRequest); + } +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/authentication/backends/ldap/connector/ConnectorFactory.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/connector/ConnectorFactory.ts new file mode 100644 index 00000000..61fef07a --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/backends/ldap/connector/ConnectorFactoryStub.spec.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/connector/ConnectorFactoryStub.spec.ts new file mode 100644 index 00000000..d11fa638 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/backends/ldap/connector/ConnectorStub.spec.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/connector/ConnectorStub.spec.ts new file mode 100644 index 00000000..0b78225b --- /dev/null +++ b/themes/triangles/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<void> { + return this.bindAsyncStub(username, password); + } + + unbindAsync(): BluebirdPromise<void> { + return this.unbindAsyncStub(); + } + + searchAsync(base: string, query: any): BluebirdPromise<any[]> { + return this.searchAsyncStub(base, query); + } + + modifyAsync(dn: string, changeRequest: any): BluebirdPromise<void> { + return this.modifyAsyncStub(dn, changeRequest); + } +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/authentication/backends/ldap/connector/IConnector.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/connector/IConnector.ts new file mode 100644 index 00000000..1e63ab19 --- /dev/null +++ b/themes/triangles/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<void>; + unbindAsync(): Bluebird<void>; + searchAsync(base: string, query: any): Bluebird<any[]>; + modifyAsync(dn: string, changeRequest: any): Bluebird<void>; +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/authentication/backends/ldap/connector/IConnectorFactory.ts b/themes/triangles/server/src/lib/authentication/backends/ldap/connector/IConnectorFactory.ts new file mode 100644 index 00000000..f9ed65ef --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/totp/ITotpHandler.ts b/themes/triangles/server/src/lib/authentication/totp/ITotpHandler.ts new file mode 100644 index 00000000..d600d31e --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/totp/TotpHandler.spec.ts b/themes/triangles/server/src/lib/authentication/totp/TotpHandler.spec.ts new file mode 100644 index 00000000..67cffa63 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/totp/TotpHandler.ts b/themes/triangles/server/src/lib/authentication/totp/TotpHandler.ts new file mode 100644 index 00000000..dfab502a --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/totp/TotpHandlerStub.spec.ts b/themes/triangles/server/src/lib/authentication/totp/TotpHandlerStub.spec.ts new file mode 100644 index 00000000..ea93330d --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/u2f/IU2fHandler.ts b/themes/triangles/server/src/lib/authentication/u2f/IU2fHandler.ts new file mode 100644 index 00000000..b9b7d6f2 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/u2f/U2fHandler.ts b/themes/triangles/server/src/lib/authentication/u2f/U2fHandler.ts new file mode 100644 index 00000000..bf3891e5 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authentication/u2f/U2fHandlerStub.spec.ts b/themes/triangles/server/src/lib/authentication/u2f/U2fHandlerStub.spec.ts new file mode 100644 index 00000000..135d7eb0 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authorization/Authorizer.spec.ts b/themes/triangles/server/src/lib/authorization/Authorizer.spec.ts new file mode 100644 index 00000000..58681404 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authorization/Authorizer.ts b/themes/triangles/server/src/lib/authorization/Authorizer.ts new file mode 100644 index 00000000..889b7ec2 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authorization/AuthorizerStub.spec.ts b/themes/triangles/server/src/lib/authorization/AuthorizerStub.spec.ts new file mode 100644 index 00000000..9bd6f4a8 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authorization/IAuthorizer.ts b/themes/triangles/server/src/lib/authorization/IAuthorizer.ts new file mode 100644 index 00000000..fe7ba367 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authorization/Level.ts b/themes/triangles/server/src/lib/authorization/Level.ts new file mode 100644 index 00000000..d1280261 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authorization/MultipleDomainMatcher.ts b/themes/triangles/server/src/lib/authorization/MultipleDomainMatcher.ts new file mode 100644 index 00000000..64c647a4 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authorization/Object.ts b/themes/triangles/server/src/lib/authorization/Object.ts new file mode 100644 index 00000000..5411b0d2 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/authorization/Subject.ts b/themes/triangles/server/src/lib/authorization/Subject.ts new file mode 100644 index 00000000..310d6b4c --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/ConfigurationParser.spec.ts b/themes/triangles/server/src/lib/configuration/ConfigurationParser.spec.ts new file mode 100644 index 00000000..60c0f618 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/ConfigurationParser.ts b/themes/triangles/server/src/lib/configuration/ConfigurationParser.ts new file mode 100644 index 00000000..d92d163c --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/SessionConfigurationBuilder.spec.ts b/themes/triangles/server/src/lib/configuration/SessionConfigurationBuilder.spec.ts new file mode 100644 index 00000000..d4a3093e --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/SessionConfigurationBuilder.ts b/themes/triangles/server/src/lib/configuration/SessionConfigurationBuilder.ts new file mode 100644 index 00000000..6ce643d9 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/AclConfiguration.spec.ts b/themes/triangles/server/src/lib/configuration/schema/AclConfiguration.spec.ts new file mode 100644 index 00000000..d1e2a03a --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/AclConfiguration.ts b/themes/triangles/server/src/lib/configuration/schema/AclConfiguration.ts new file mode 100644 index 00000000..40401dd6 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.spec.ts b/themes/triangles/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.spec.ts new file mode 100644 index 00000000..3ca86381 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.ts b/themes/triangles/server/src/lib/configuration/schema/AuthenticationBackendConfiguration.ts new file mode 100644 index 00000000..7f77f894 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/Configuration.ts b/themes/triangles/server/src/lib/configuration/schema/Configuration.ts new file mode 100644 index 00000000..8d16a5fb --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/FileUsersDatabaseConfiguration.ts b/themes/triangles/server/src/lib/configuration/schema/FileUsersDatabaseConfiguration.ts new file mode 100644 index 00000000..d19002ba --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/LdapConfiguration.spec.ts b/themes/triangles/server/src/lib/configuration/schema/LdapConfiguration.spec.ts new file mode 100644 index 00000000..cc73d108 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/LdapConfiguration.ts b/themes/triangles/server/src/lib/configuration/schema/LdapConfiguration.ts new file mode 100644 index 00000000..5dacb939 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/NotifierConfiguration.spec.ts b/themes/triangles/server/src/lib/configuration/schema/NotifierConfiguration.spec.ts new file mode 100644 index 00000000..6c576e8e --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/NotifierConfiguration.ts b/themes/triangles/server/src/lib/configuration/schema/NotifierConfiguration.ts new file mode 100644 index 00000000..7bcce15c --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/RegulationConfiguration.spec.ts b/themes/triangles/server/src/lib/configuration/schema/RegulationConfiguration.spec.ts new file mode 100644 index 00000000..dce2caf4 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/RegulationConfiguration.ts b/themes/triangles/server/src/lib/configuration/schema/RegulationConfiguration.ts new file mode 100644 index 00000000..117463f4 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/SessionConfiguration.spec.ts b/themes/triangles/server/src/lib/configuration/schema/SessionConfiguration.spec.ts new file mode 100644 index 00000000..e5401083 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/SessionConfiguration.ts b/themes/triangles/server/src/lib/configuration/schema/SessionConfiguration.ts new file mode 100644 index 00000000..2c88bb21 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/StorageConfiguration.spec.ts b/themes/triangles/server/src/lib/configuration/schema/StorageConfiguration.spec.ts new file mode 100644 index 00000000..9d02a11b --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/StorageConfiguration.ts b/themes/triangles/server/src/lib/configuration/schema/StorageConfiguration.ts new file mode 100644 index 00000000..47e356ef --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/TotpConfiguration.ts b/themes/triangles/server/src/lib/configuration/schema/TotpConfiguration.ts new file mode 100644 index 00000000..68313563 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/configuration/schema/UserDatabaseConfiguration.ts b/themes/triangles/server/src/lib/configuration/schema/UserDatabaseConfiguration.ts new file mode 100644 index 00000000..8008b483 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/connectors/mongo/IMongoClient.d.ts b/themes/triangles/server/src/lib/connectors/mongo/IMongoClient.d.ts new file mode 100644 index 00000000..36cb4b8b --- /dev/null +++ b/themes/triangles/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<MongoDB.Collection> +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/connectors/mongo/MongoClient.spec.ts b/themes/triangles/server/src/lib/connectors/mongo/MongoClient.spec.ts new file mode 100644 index 00000000..ca0c6859 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/connectors/mongo/MongoClient.ts b/themes/triangles/server/src/lib/connectors/mongo/MongoClient.ts new file mode 100644 index 00000000..d15731e9 --- /dev/null +++ b/themes/triangles/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<void> { + 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<void> { + if (this.client) { + this.client.close(); + this.database = undefined; + this.client = undefined; + } + return Bluebird.resolve(); + } + + collection(name: string): Bluebird<MongoDB.Collection> { + 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/triangles/server/src/lib/connectors/mongo/MongoClientStub.spec.ts b/themes/triangles/server/src/lib/connectors/mongo/MongoClientStub.spec.ts new file mode 100644 index 00000000..1cfd48e3 --- /dev/null +++ b/themes/triangles/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<MongoDB.Collection> { + return this.collectionStub(name); + } +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/logging/GlobalLogger.ts b/themes/triangles/server/src/lib/logging/GlobalLogger.ts new file mode 100644 index 00000000..4da7acf4 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/logging/GlobalLoggerStub.spec.ts b/themes/triangles/server/src/lib/logging/GlobalLoggerStub.spec.ts new file mode 100644 index 00000000..d4bb1371 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/logging/IGlobalLogger.ts b/themes/triangles/server/src/lib/logging/IGlobalLogger.ts new file mode 100644 index 00000000..548515ec --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/logging/IRequestLogger.ts b/themes/triangles/server/src/lib/logging/IRequestLogger.ts new file mode 100644 index 00000000..126a601f --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/logging/RequestLogger.ts b/themes/triangles/server/src/lib/logging/RequestLogger.ts new file mode 100644 index 00000000..c45c6601 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/logging/RequestLoggerStub.spec.ts b/themes/triangles/server/src/lib/logging/RequestLoggerStub.spec.ts new file mode 100644 index 00000000..b0e37521 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/notifiers/AbstractEmailNotifier.ts b/themes/triangles/server/src/lib/notifiers/AbstractEmailNotifier.ts new file mode 100644 index 00000000..198e4e5d --- /dev/null +++ b/themes/triangles/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<void> { + 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<void>; +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/notifiers/EmailNotifier.spec.ts b/themes/triangles/server/src/lib/notifiers/EmailNotifier.spec.ts new file mode 100644 index 00000000..8211bbc0 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/notifiers/EmailNotifier.ts b/themes/triangles/server/src/lib/notifiers/EmailNotifier.ts new file mode 100644 index 00000000..4df7c861 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/notifiers/FileSystemNotifier.ts b/themes/triangles/server/src/lib/notifiers/FileSystemNotifier.ts new file mode 100644 index 00000000..23f6242c --- /dev/null +++ b/themes/triangles/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<void> { + 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/triangles/server/src/lib/notifiers/IMailSender.ts b/themes/triangles/server/src/lib/notifiers/IMailSender.ts new file mode 100644 index 00000000..34ac464a --- /dev/null +++ b/themes/triangles/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<void>; +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/notifiers/IMailSenderBuilder.ts b/themes/triangles/server/src/lib/notifiers/IMailSenderBuilder.ts new file mode 100644 index 00000000..36d4dcdf --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/notifiers/INotifier.ts b/themes/triangles/server/src/lib/notifiers/INotifier.ts new file mode 100644 index 00000000..b9a6b138 --- /dev/null +++ b/themes/triangles/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<void>; +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/notifiers/MailSender.ts b/themes/triangles/server/src/lib/notifiers/MailSender.ts new file mode 100644 index 00000000..536a88e6 --- /dev/null +++ b/themes/triangles/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<void> { + 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<void> { + 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/triangles/server/src/lib/notifiers/MailSenderBuilder.spec.ts b/themes/triangles/server/src/lib/notifiers/MailSenderBuilder.spec.ts new file mode 100644 index 00000000..41e0db42 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/notifiers/MailSenderBuilder.ts b/themes/triangles/server/src/lib/notifiers/MailSenderBuilder.ts new file mode 100644 index 00000000..1d06be52 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/notifiers/MailSenderBuilderStub.spec.ts b/themes/triangles/server/src/lib/notifiers/MailSenderBuilderStub.spec.ts new file mode 100644 index 00000000..5b76f6e5 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/notifiers/MailSenderStub.spec.ts b/themes/triangles/server/src/lib/notifiers/MailSenderStub.spec.ts new file mode 100644 index 00000000..d57c458f --- /dev/null +++ b/themes/triangles/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<void> { + return this.sendStub(mailOptions); + } +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/notifiers/NotifierFactory.spec.ts b/themes/triangles/server/src/lib/notifiers/NotifierFactory.spec.ts new file mode 100644 index 00000000..f15e7667 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/notifiers/NotifierFactory.ts b/themes/triangles/server/src/lib/notifiers/NotifierFactory.ts new file mode 100644 index 00000000..a89155fe --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/notifiers/NotifierStub.spec.ts b/themes/triangles/server/src/lib/notifiers/NotifierStub.spec.ts new file mode 100644 index 00000000..f99231b5 --- /dev/null +++ b/themes/triangles/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<void> { + return this.notifyStub(to, subject, link); + } +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/notifiers/SmtpNotifier.ts b/themes/triangles/server/src/lib/notifiers/SmtpNotifier.ts new file mode 100644 index 00000000..f93a6d4a --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/regulation/IRegulator.ts b/themes/triangles/server/src/lib/regulation/IRegulator.ts new file mode 100644 index 00000000..c49425b2 --- /dev/null +++ b/themes/triangles/server/src/lib/regulation/IRegulator.ts @@ -0,0 +1,6 @@ +import BluebirdPromise = require("bluebird"); + +export interface IRegulator { + mark(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise<void>; + regulate(userId: string): BluebirdPromise<void>; +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/regulation/Regulator.spec.ts b/themes/triangles/server/src/lib/regulation/Regulator.spec.ts new file mode 100644 index 00000000..f9c6e608 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/regulation/Regulator.ts b/themes/triangles/server/src/lib/regulation/Regulator.ts new file mode 100644 index 00000000..1037a6a1 --- /dev/null +++ b/themes/triangles/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<void> { + return this.userDataStore.saveAuthenticationTrace(userId, isAuthenticationSuccessful); + } + + regulate(userId: string): BluebirdPromise<void> { + 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/triangles/server/src/lib/regulation/RegulatorStub.spec.ts b/themes/triangles/server/src/lib/regulation/RegulatorStub.spec.ts new file mode 100644 index 00000000..ca8a00fb --- /dev/null +++ b/themes/triangles/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<void> { + return this.markStub(userId, isAuthenticationSuccessful); + } + + regulate(userId: string): Bluebird<void> { + return this.regulateStub(userId); + } +} diff --git a/themes/triangles/server/src/lib/routes/error/401/get.spec.ts b/themes/triangles/server/src/lib/routes/error/401/get.spec.ts new file mode 100644 index 00000000..9fdac9c3 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/error/401/get.ts b/themes/triangles/server/src/lib/routes/error/401/get.ts new file mode 100644 index 00000000..ca4a3963 --- /dev/null +++ b/themes/triangles/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<void> { + const redirectionUrl = redirector(req, vars); + res.render("errors/401", { + redirection_url: redirectionUrl + }); + return BluebirdPromise.resolve(); + }; +} diff --git a/themes/triangles/server/src/lib/routes/error/403/get.spec.ts b/themes/triangles/server/src/lib/routes/error/403/get.spec.ts new file mode 100644 index 00000000..22eb8485 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/error/403/get.ts b/themes/triangles/server/src/lib/routes/error/403/get.ts new file mode 100644 index 00000000..3ab0319e --- /dev/null +++ b/themes/triangles/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<void> { + 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/triangles/server/src/lib/routes/error/404/get.spec.ts b/themes/triangles/server/src/lib/routes/error/404/get.spec.ts new file mode 100644 index 00000000..73e4e6ce --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/error/404/get.ts b/themes/triangles/server/src/lib/routes/error/404/get.ts new file mode 100644 index 00000000..6693b6fc --- /dev/null +++ b/themes/triangles/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<void> { + res.render("errors/404"); + return BluebirdPromise.resolve(); +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/routes/error/redirector.ts b/themes/triangles/server/src/lib/routes/error/redirector.ts new file mode 100644 index 00000000..b1a3ccc1 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/firstfactor/get.ts b/themes/triangles/server/src/lib/routes/firstfactor/get.ts new file mode 100644 index 00000000..d94f656c --- /dev/null +++ b/themes/triangles/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<void> { + 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/triangles/server/src/lib/routes/firstfactor/post.spec.ts b/themes/triangles/server/src/lib/routes/firstfactor/post.spec.ts new file mode 100644 index 00000000..e1d078cd --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/firstfactor/post.ts b/themes/triangles/server/src/lib/routes/firstfactor/post.ts new file mode 100644 index 00000000..565681d6 --- /dev/null +++ b/themes/triangles/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<void> { + 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/triangles/server/src/lib/routes/loggedin/get.ts b/themes/triangles/server/src/lib/routes/loggedin/get.ts new file mode 100644 index 00000000..283a041b --- /dev/null +++ b/themes/triangles/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<void> { + return new BluebirdPromise<void>(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/triangles/server/src/lib/routes/logout/get.ts b/themes/triangles/server/src/lib/routes/logout/get.ts new file mode 100644 index 00000000..4d511214 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/password-reset/constants.ts b/themes/triangles/server/src/lib/routes/password-reset/constants.ts new file mode 100644 index 00000000..5c639e92 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/password-reset/form/post.spec.ts b/themes/triangles/server/src/lib/routes/password-reset/form/post.spec.ts new file mode 100644 index 00000000..ed029c90 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/password-reset/form/post.ts b/themes/triangles/server/src/lib/routes/password-reset/form/post.ts new file mode 100644 index 00000000..fccd7471 --- /dev/null +++ b/themes/triangles/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<void> { + let authSession: AuthenticationSession; + const newPassword = objectPath.get<express.Request, string>(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/triangles/server/src/lib/routes/password-reset/identity/PasswordResetHandler.spec.ts b/themes/triangles/server/src/lib/routes/password-reset/identity/PasswordResetHandler.spec.ts new file mode 100644 index 00000000..ac6a4175 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/password-reset/identity/PasswordResetHandler.ts b/themes/triangles/server/src/lib/routes/password-reset/identity/PasswordResetHandler.ts new file mode 100644 index 00000000..42ae92cd --- /dev/null +++ b/themes/triangles/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<Identity> { + const that = this; + const userid: string = + objectPath.get<express.Request, string>(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/triangles/server/src/lib/routes/password-reset/request/get.ts b/themes/triangles/server/src/lib/routes/password-reset/request/get.ts new file mode 100644 index 00000000..8f3ae2b4 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/secondfactor/get.spec.ts b/themes/triangles/server/src/lib/routes/secondfactor/get.spec.ts new file mode 100644 index 00000000..6c77e1f6 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/secondfactor/get.ts b/themes/triangles/server/src/lib/routes/secondfactor/get.ts new file mode 100644 index 00000000..9f6deb4c --- /dev/null +++ b/themes/triangles/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<void> { + + 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/triangles/server/src/lib/routes/secondfactor/redirect.spec.ts b/themes/triangles/server/src/lib/routes/secondfactor/redirect.spec.ts new file mode 100644 index 00000000..ea66e6dc --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/secondfactor/redirect.ts b/themes/triangles/server/src/lib/routes/secondfactor/redirect.ts new file mode 100644 index 00000000..5d84d9eb --- /dev/null +++ b/themes/triangles/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<void> { + + return new BluebirdPromise<void>(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/triangles/server/src/lib/routes/secondfactor/totp/constants.ts b/themes/triangles/server/src/lib/routes/secondfactor/totp/constants.ts new file mode 100644 index 00000000..7b5a1dcf --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.spec.ts b/themes/triangles/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.spec.ts new file mode 100644 index 00000000..78b8ea3e --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.ts b/themes/triangles/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.ts new file mode 100644 index 00000000..b39b6d04 --- /dev/null +++ b/themes/triangles/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<Identity> { + 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<Identity> { + 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<void> { + 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/triangles/server/src/lib/routes/secondfactor/totp/sign/post.spec.ts b/themes/triangles/server/src/lib/routes/secondfactor/totp/sign/post.spec.ts new file mode 100644 index 00000000..70a20d39 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/secondfactor/totp/sign/post.ts b/themes/triangles/server/src/lib/routes/secondfactor/totp/sign/post.ts new file mode 100644 index 00000000..34a276d1 --- /dev/null +++ b/themes/triangles/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<void> { + 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/triangles/server/src/lib/routes/secondfactor/u2f/U2FCommon.ts b/themes/triangles/server/src/lib/routes/secondfactor/u2f/U2FCommon.ts new file mode 100644 index 00000000..7f16c0ee --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.spec.ts b/themes/triangles/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.spec.ts new file mode 100644 index 00000000..a54bfbfe --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.ts b/themes/triangles/server/src/lib/routes/secondfactor/u2f/identity/RegistrationHandler.ts new file mode 100644 index 00000000..bc4713c7 --- /dev/null +++ b/themes/triangles/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<Identity> { + 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<Identity> { + 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/triangles/server/src/lib/routes/secondfactor/u2f/register/post.spec.ts b/themes/triangles/server/src/lib/routes/secondfactor/u2f/register/post.spec.ts new file mode 100644 index 00000000..de3347a2 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/secondfactor/u2f/register/post.ts b/themes/triangles/server/src/lib/routes/secondfactor/u2f/register/post.ts new file mode 100644 index 00000000..7296ccbe --- /dev/null +++ b/themes/triangles/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<void> { + 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<void> { + 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/triangles/server/src/lib/routes/secondfactor/u2f/register_request/get.spec.ts b/themes/triangles/server/src/lib/routes/secondfactor/u2f/register_request/get.spec.ts new file mode 100644 index 00000000..a207c910 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/secondfactor/u2f/register_request/get.ts b/themes/triangles/server/src/lib/routes/secondfactor/u2f/register_request/get.ts new file mode 100644 index 00000000..f611af93 --- /dev/null +++ b/themes/triangles/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<void> { + 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/triangles/server/src/lib/routes/secondfactor/u2f/sign/post.spec.ts b/themes/triangles/server/src/lib/routes/secondfactor/u2f/sign/post.spec.ts new file mode 100644 index 00000000..9b137e66 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/secondfactor/u2f/sign/post.ts b/themes/triangles/server/src/lib/routes/secondfactor/u2f/sign/post.ts new file mode 100644 index 00000000..7ee711c2 --- /dev/null +++ b/themes/triangles/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<void> { + 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<U2f.SignatureResult | U2f.Error> { + 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<void> { + 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/triangles/server/src/lib/routes/secondfactor/u2f/sign_request/get.spec.ts b/themes/triangles/server/src/lib/routes/secondfactor/u2f/sign_request/get.spec.ts new file mode 100644 index 00000000..dd52b27e --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/secondfactor/u2f/sign_request/get.ts b/themes/triangles/server/src/lib/routes/secondfactor/u2f/sign_request/get.ts new file mode 100644 index 00000000..9e93dde0 --- /dev/null +++ b/themes/triangles/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<void> { + 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<void> { + 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/triangles/server/src/lib/routes/verify/access_control.ts b/themes/triangles/server/src/lib/routes/verify/access_control.ts new file mode 100644 index 00000000..136239ae --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/verify/get.spec.ts b/themes/triangles/server/src/lib/routes/verify/get.spec.ts new file mode 100644 index 00000000..67cf19fb --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/routes/verify/get.ts b/themes/triangles/server/src/lib/routes/verify/get.ts new file mode 100644 index 00000000..f7386169 --- /dev/null +++ b/themes/triangles/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<Express.Request, string>( + 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<void> { + 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/triangles/server/src/lib/routes/verify/get_basic_auth.ts b/themes/triangles/server/src/lib/routes/verify/get_basic_auth.ts new file mode 100644 index 00000000..af23c76c --- /dev/null +++ b/themes/triangles/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<Express.Request, string>(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/triangles/server/src/lib/routes/verify/get_session_cookie.ts b/themes/triangles/server/src/lib/routes/verify/get_session_cookie.ts new file mode 100644 index 00000000..07034481 --- /dev/null +++ b/themes/triangles/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<void> { + + // 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<Express.Request, string>( + req, "headers.x-original-url"); + const originalUri = + ObjectPath.get<Express.Request, string>(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/triangles/server/src/lib/storage/AuthenticationTraceDocument.d.ts b/themes/triangles/server/src/lib/storage/AuthenticationTraceDocument.d.ts new file mode 100644 index 00000000..69818c05 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/storage/CollectionFactoryFactory.ts b/themes/triangles/server/src/lib/storage/CollectionFactoryFactory.ts new file mode 100644 index 00000000..92b29abf --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/storage/CollectionFactoryStub.spec.ts b/themes/triangles/server/src/lib/storage/CollectionFactoryStub.spec.ts new file mode 100644 index 00000000..17f8bb02 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/storage/CollectionStub.spec.ts b/themes/triangles/server/src/lib/storage/CollectionStub.spec.ts new file mode 100644 index 00000000..42895d67 --- /dev/null +++ b/themes/triangles/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<any> { + return this.findStub(filter, sortKeys, count); + } + + findOne(filter: any): BluebirdPromise<any> { + return this.findOneStub(filter); + } + + update(filter: any, document: any, options: any): BluebirdPromise<any> { + return this.updateStub(filter, document, options); + } + + remove(filter: any): BluebirdPromise<any> { + return this.removeStub(filter); + } + + insert(document: any): BluebirdPromise<any> { + return this.insertStub(document); + } +} diff --git a/themes/triangles/server/src/lib/storage/ICollection.d.ts b/themes/triangles/server/src/lib/storage/ICollection.d.ts new file mode 100644 index 00000000..caa6c2a8 --- /dev/null +++ b/themes/triangles/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<any>; + findOne(query: any): BluebirdPromise<any>; + update(query: any, updateQuery: any, options?: any): BluebirdPromise<any>; + remove(query: any): BluebirdPromise<any>; + insert(document: any): BluebirdPromise<any>; +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/storage/ICollectionFactory.d.ts b/themes/triangles/server/src/lib/storage/ICollectionFactory.d.ts new file mode 100644 index 00000000..39eb42c7 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/storage/IUserDataStore.d.ts b/themes/triangles/server/src/lib/storage/IUserDataStore.d.ts new file mode 100644 index 00000000..81df482a --- /dev/null +++ b/themes/triangles/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<void>; + retrieveU2FRegistration(userId: string, appId: string): BluebirdPromise<U2FRegistrationDocument>; + + saveAuthenticationTrace(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise<void>; + retrieveLatestAuthenticationTraces(userId: string, count: number): BluebirdPromise<AuthenticationTraceDocument[]>; + + produceIdentityValidationToken(userId: string, token: string, challenge: string, maxAge: number): BluebirdPromise<any>; + consumeIdentityValidationToken(token: string, challenge: string): BluebirdPromise<IdentityValidationDocument>; + + saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise<void>; + retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument>; +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/storage/IdentityValidationDocument.d.ts b/themes/triangles/server/src/lib/storage/IdentityValidationDocument.d.ts new file mode 100644 index 00000000..e7fd7b3f --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/storage/TOTPSecretDocument.d.ts b/themes/triangles/server/src/lib/storage/TOTPSecretDocument.d.ts new file mode 100644 index 00000000..a6c0bf9e --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/storage/U2FRegistrationDocument.d.ts b/themes/triangles/server/src/lib/storage/U2FRegistrationDocument.d.ts new file mode 100644 index 00000000..efec6cb1 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/storage/UserDataStore.spec.ts b/themes/triangles/server/src/lib/storage/UserDataStore.spec.ts new file mode 100644 index 00000000..66fb8546 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/storage/UserDataStore.ts b/themes/triangles/server/src/lib/storage/UserDataStore.ts new file mode 100644 index 00000000..27b0cddb --- /dev/null +++ b/themes/triangles/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<void> { + 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<U2FRegistrationDocument> { + const filter: U2FRegistrationKey = { + userId: userId, + appId: appId + }; + return this.u2fSecretCollection.findOne(filter); + } + + saveAuthenticationTrace(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise<void> { + const newDocument: AuthenticationTraceDocument = { + userId: userId, + date: new Date(), + isAuthenticationSuccessful: isAuthenticationSuccessful, + }; + + return this.authenticationTracesCollection.insert(newDocument); + } + + retrieveLatestAuthenticationTraces(userId: string, count: number): BluebirdPromise<AuthenticationTraceDocument[]> { + const q = { + userId: userId + }; + + return this.authenticationTracesCollection.find(q, { date: -1 }, count); + } + + produceIdentityValidationToken(userId: string, token: string, challenge: string, maxAge: number): BluebirdPromise<any> { + 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<IdentityValidationDocument> { + 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<void> { + const doc = { + userId: userId, + secret: secret + }; + + const filter = { + userId: userId + }; + return this.totpSecretCollection.update(filter, doc, { upsert: true }); + } + + retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument> { + const filter = { + userId: userId + }; + return this.totpSecretCollection.findOne(filter); + } +} diff --git a/themes/triangles/server/src/lib/storage/UserDataStoreStub.spec.ts b/themes/triangles/server/src/lib/storage/UserDataStoreStub.spec.ts new file mode 100644 index 00000000..5ea27a2d --- /dev/null +++ b/themes/triangles/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<void> { + return this.saveU2FRegistrationStub(userId, appId, registration); + } + + retrieveU2FRegistration(userId: string, appId: string): BluebirdPromise<U2FRegistrationDocument> { + return this.retrieveU2FRegistrationStub(userId, appId); + } + + saveAuthenticationTrace(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise<void> { + return this.saveAuthenticationTraceStub(userId, isAuthenticationSuccessful); + } + + retrieveLatestAuthenticationTraces(userId: string, count: number): BluebirdPromise<AuthenticationTraceDocument[]> { + return this.retrieveLatestAuthenticationTracesStub(userId, count); + } + + produceIdentityValidationToken(userId: string, token: string, challenge: string, maxAge: number): BluebirdPromise<any> { + return this.produceIdentityValidationTokenStub(userId, token, challenge, maxAge); + } + + consumeIdentityValidationToken(token: string, challenge: string): BluebirdPromise<IdentityValidationDocument> { + return this.consumeIdentityValidationTokenStub(token, challenge); + } + + saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise<void> { + return this.saveTOTPSecretStub(userId, secret); + } + + retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument> { + return this.retrieveTOTPSecretStub(userId); + } +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/storage/mongo/MongoCollection.spec.ts b/themes/triangles/server/src/lib/storage/mongo/MongoCollection.spec.ts new file mode 100644 index 00000000..74a773a1 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/storage/mongo/MongoCollection.ts b/themes/triangles/server/src/lib/storage/mongo/MongoCollection.ts new file mode 100644 index 00000000..9771389f --- /dev/null +++ b/themes/triangles/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<MongoDB.Collection> { + return this.mongoClient.collection(this.collectionName); + } + + find(query: any, sortKeys?: any, count?: number): Bluebird<any> { + return this.collection() + .then((collection) => collection.find(query).sort(sortKeys).limit(count)) + .then((query) => query.toArray()); + } + + findOne(query: any): Bluebird<any> { + return this.collection() + .then((collection) => collection.findOne(query)); + } + + update(query: any, updateQuery: any, options?: any): Bluebird<any> { + return this.collection() + .then((collection) => collection.update(query, updateQuery, options)); + } + + remove(query: any): Bluebird<any> { + return this.collection() + .then((collection) => collection.remove(query)); + } + + insert(document: any): Bluebird<any> { + return this.collection() + .then((collection) => collection.insertOne(document)); + } + + count(query: any): Bluebird<any> { + return this.collection() + .then((collection) => collection.count(query)); + } +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/storage/mongo/MongoCollectionFactory.spec.ts b/themes/triangles/server/src/lib/storage/mongo/MongoCollectionFactory.spec.ts new file mode 100644 index 00000000..bd959cac --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/storage/mongo/MongoCollectionFactory.ts b/themes/triangles/server/src/lib/storage/mongo/MongoCollectionFactory.ts new file mode 100644 index 00000000..14a8262c --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/storage/nedb/NedbCollection.spec.ts b/themes/triangles/server/src/lib/storage/nedb/NedbCollection.spec.ts new file mode 100644 index 00000000..a69962b6 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/storage/nedb/NedbCollection.ts b/themes/triangles/server/src/lib/storage/nedb/NedbCollection.ts new file mode 100644 index 00000000..88a93ad0 --- /dev/null +++ b/themes/triangles/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<any>; + findOneAsync<T>(query: any): BluebirdPromise<T>; + insertAsync<T>(newDoc: T): BluebirdPromise<any>; + removeAsync(query: any): BluebirdPromise<any>; + countAsync(query: any): BluebirdPromise<number>; + } +} + +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<any> { + const q = this.collection.find(query).sort(sortKeys).limit(count); + return BluebirdPromise.promisify(q.exec, { context: q })(); + } + + findOne(query: any): BluebirdPromise<any> { + return this.collection.findOneAsync(query); + } + + update(query: any, updateQuery: any, options?: any): BluebirdPromise<any> { + return this.collection.updateAsync(query, updateQuery, options); + } + + remove(query: any): BluebirdPromise<any> { + return this.collection.removeAsync(query); + } + + insert(document: any): BluebirdPromise<any> { + return this.collection.insertAsync(document); + } + + count(query: any): BluebirdPromise<number> { + return this.collection.countAsync(query); + } +} \ No newline at end of file diff --git a/themes/triangles/server/src/lib/storage/nedb/NedbCollectionFactory.spec.ts b/themes/triangles/server/src/lib/storage/nedb/NedbCollectionFactory.spec.ts new file mode 100644 index 00000000..da90c661 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/storage/nedb/NedbCollectionFactory.ts b/themes/triangles/server/src/lib/storage/nedb/NedbCollectionFactory.ts new file mode 100644 index 00000000..49c4dc85 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/stubs/express.spec.ts b/themes/triangles/server/src/lib/stubs/express.spec.ts new file mode 100644 index 00000000..48f15d7e --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/stubs/ldapjs.spec.ts b/themes/triangles/server/src/lib/stubs/ldapjs.spec.ts new file mode 100644 index 00000000..045c0e11 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/stubs/speakeasy.spec.ts b/themes/triangles/server/src/lib/stubs/speakeasy.spec.ts new file mode 100644 index 00000000..023614dc --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/stubs/u2f.spec.ts b/themes/triangles/server/src/lib/stubs/u2f.spec.ts new file mode 100644 index 00000000..234b28c1 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/utils/HashGenerator.spec.ts b/themes/triangles/server/src/lib/utils/HashGenerator.spec.ts new file mode 100644 index 00000000..f19619a6 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/utils/HashGenerator.ts b/themes/triangles/server/src/lib/utils/HashGenerator.ts new file mode 100644 index 00000000..e67de32b --- /dev/null +++ b/themes/triangles/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<string> { + const saltSize = 16; + // $6 means SHA512 + const _salt = Util.format("$6$rounds=%d$%s", rounds, + (salt) ? salt : RandomString.generate(16)); + + const cryptAsync = BluebirdPromise.promisify<string, string, string>(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/triangles/server/src/lib/utils/ObjectCloner.ts b/themes/triangles/server/src/lib/utils/ObjectCloner.ts new file mode 100644 index 00000000..3e125d74 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/utils/SafeRedirection.spec.ts b/themes/triangles/server/src/lib/utils/SafeRedirection.spec.ts new file mode 100644 index 00000000..4126949f --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/utils/SafeRedirection.ts b/themes/triangles/server/src/lib/utils/SafeRedirection.ts new file mode 100644 index 00000000..9e6a32e0 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/utils/URLDecomposer.spec.ts b/themes/triangles/server/src/lib/utils/URLDecomposer.spec.ts new file mode 100644 index 00000000..cbb03873 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/utils/URLDecomposer.ts b/themes/triangles/server/src/lib/utils/URLDecomposer.ts new file mode 100644 index 00000000..9bdf2e9d --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/web_server/Configurator.ts b/themes/triangles/server/src/lib/web_server/Configurator.ts new file mode 100644 index 00000000..6e404874 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/web_server/RestApi.ts b/themes/triangles/server/src/lib/web_server/RestApi.ts new file mode 100644 index 00000000..9144a15b --- /dev/null +++ b/themes/triangles/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/triangles/server/src/lib/web_server/middlewares/RequireValidatedFirstFactor.ts b/themes/triangles/server/src/lib/web_server/middlewares/RequireValidatedFirstFactor.ts new file mode 100644 index 00000000..ecfd7576 --- /dev/null +++ b/themes/triangles/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<void> { + + return new BluebirdPromise<void>(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/triangles/server/src/lib/web_server/middlewares/WithHeadersLogged.ts b/themes/triangles/server/src/lib/web_server/middlewares/WithHeadersLogged.ts new file mode 100644 index 00000000..139db114 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/resources/email-template.ejs b/themes/triangles/server/src/resources/email-template.ejs new file mode 100644 index 00000000..f59c2f94 --- /dev/null +++ b/themes/triangles/server/src/resources/email-template.ejs @@ -0,0 +1,254 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> + <title>Simples-Minimalistic Responsive Template</title> + + <style type="text/css"> + /* Client-specific Styles */ + #outlook a {padding:0;} /* Force Outlook to provide a "view in browser" menu link. */ + body{background: rgb(0, 0, 0);width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0;} + /* Prevent Webkit and Windows Mobile platforms from changing default font sizes, while not breaking desktop design. */ + .ExternalClass {width:100%;} /* Force Hotmail to display emails at full width */ + .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;} /* Force Hotmail to display normal line spacing.*/ + #backgroundTable {margin:0; padding:0; width:100% !important; line-height: 100% !important;} + img {outline:none; text-decoration:none;border:none; -ms-interpolation-mode: bicubic;} + a img {border:none;} + .image_fix {display:block;} + p {margin: 0px 0px !important;} + table td {border-collapse: collapse;} + table { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; } + a {color: #ffffff;text-decoration: none;text-decoration:none!important;} + h1 {color: #ffffff; line-height: 30px; } + .button {padding: 15px 30px; border-radius: 10px; background: rgb(3, 183, 3); text-decoration:none; } + + /*STYLES*/ + table[class=full] { width: 100%; clear: both; } + /*IPAD STYLES*/ + @media only screen and (max-width: 640px) { + a[href^="tel"], a[href^="sms"] { + text-decoration: none; + color: #ffffff; /* or whatever your want */ + pointer-events: none; + cursor: default; + } + .mobile_link a[href^="tel"], .mobile_link a[href^="sms"] { + text-decoration: default; + color: #000000 !important; + pointer-events: auto; + cursor: default; + } + table[class=devicewidth] {width: 440px!important;text-align:center!important;} + table[class=devicewidthinner] {width: 420px!important;text-align:center!important;} + img[class=banner] {width: 440px!important;height:220px!important;} + img[class=colimg2] {width: 440px!important;height:220px!important;} + + } + /*IPHONE STYLES*/ + @media only screen and (max-width: 480px) { + a[href^="tel"], a[href^="sms"] { + text-decoration: none; + color: #000000; /* or whatever your want */ + pointer-events: none; + cursor: default; + } + .mobile_link a[href^="tel"], .mobile_link a[href^="sms"] { + text-decoration: default; + color: #000000 !important; + pointer-events: auto; + cursor: default; + } + table[class=devicewidth] {width: 280px!important;text-align:center!important;} + table[class=devicewidthinner] {width: 260px!important;text-align:center!important;} + img[class=banner] {width: 280px!important;height:140px!important;} + img[class=colimg2] {width: 280px!important;height:140px!important;} + td[class=mobile-hide]{display:none!important;} + td[class="padding-bottom25"]{padding-bottom:25px!important;} + + } + </style> + </head> + <body> +<!-- Start of header --> +<table width="100%" bgcolor="#000000" cellpadding="0" cellspacing="0" border="0" id="backgroundTable" st-sortable="header"> + <tbody> + <tr> + <td> + <table width="600" cellpadding="0" cellspacing="0" border="0" align="center" class="devicewidth"> + <tbody> + <tr> + <td width="100%"> + <table width="600" cellpadding="0" cellspacing="0" border="0" align="center" class="devicewidth"> + <tbody> + <!-- Spacing --> + <tr> + <td height="20" style="font-size:1px; line-height:1px; mso-line-height-rule: exactly;"> </td> + </tr> + <!-- Spacing --> + <tr> + <td> + <!-- logo --> + <table width="140" align="center" border="0" cellpadding="0" cellspacing="0" class="devicewidth"> + <tbody> + <tr> + <td width="300" height="50" align="center"> + <h1><%= title %></h1> + </td> + </tr> + </tbody> + </table> + <!-- end of logo --> + </td> + </tr> + <!-- Spacing --> + <tr> + <td height="20" style="font-size:1px; line-height:1px; mso-line-height-rule: exactly;"> </td> + </tr> + <!-- Spacing --> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> +</table> +<!-- End of Header --> +<!-- Start of seperator --> +<table width="100%" bgcolor="#000000" cellpadding="0" cellspacing="0" border="0" id="backgroundTable" st-sortable="seperator"> + <tbody> + <tr> + <td> + <table width="600" align="center" cellspacing="0" cellpadding="0" border="0" class="devicewidth"> + <tbody> + <tr> + <td align="center" height="20" style="font-size:1px; line-height:1px;"> </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> +</table> +<!-- End of seperator --> +<!-- Start Full Text --> +<table width="100%" bgcolor="#000000" cellpadding="0" cellspacing="0" border="0" id="backgroundTable" st-sortable="full-text"> + <tbody> + <tr> + <td> + <table width="600" cellpadding="0" cellspacing="0" border="0" align="center" class="devicewidth"> + <tbody> + <tr> + <td width="100%"> + <table width="600" cellpadding="0" cellspacing="0" border="0" align="center" class="devicewidth"> + <tbody> + <!-- Spacing --> + <tr> + <td height="20" style="font-size:1px; line-height:1px; mso-line-height-rule: exactly;"> </td> + </tr> + <!-- Spacing --> + <tr> + <td> + <table width="560" align="center" cellpadding="0" cellspacing="0" border="0" class="devicewidthinner"> + <tbody> + <!-- Title --> + <tr> + <td style="font-family: Helvetica, arial, sans-serif; font-size: 16px; color: #ffffff; text-align:center; line-height: 30px;" st-title="fulltext-content"> + 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. + </td> + </tr> + <!-- End of Title --> + <!-- spacing --> + <tr> + <td width="100%" height="20" style="font-size:1px; line-height:1px; mso-line-height-rule: exactly;"> </td> + </tr> + <!-- End of spacing --> + <!-- content --> + <tr> + <td style="font-family: Helvetica, arial, sans-serif; font-size: 16px; color: #666666; text-align:center; line-height: 30px;" st-content="fulltext-content"> + <a href="<%= url %>" class="button"><%= button_title %></a> + </td> + </tr> + <!-- End of content --> + </tbody> + </table> + </td> + </tr> + <!-- Spacing --> + <tr> + <td height="20" style="font-size:1px; line-height:1px; mso-line-height-rule: exactly;"> </td> + </tr> + <!-- Spacing --> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> +</table> +<!-- end of full text --> +<!-- Start of seperator --> +<table width="100%" bgcolor="#000000" cellpadding="0" cellspacing="0" border="0" id="backgroundTable" st-sortable="seperator"> + <tbody> + <tr> + <td> + <table width="600" align="center" cellspacing="0" cellpadding="0" border="0" class="devicewidth"> + <tbody> + <tr> + <td align="center" height="30" style="font-size:1px; line-height:1px;"> </td> + </tr> + <tr> + <td width="550" align="center" height="1" bgcolor="#d1d1d1" style="font-size:1px; line-height:1px;"> </td> + </tr> + <tr> + <td align="center" height="30" style="font-size:1px; line-height:1px;"> </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> +</table> +<!-- End of seperator --> +<!-- Start of Postfooter --> +<table width="100%" bgcolor="#000000" cellpadding="0" cellspacing="0" border="0" id="backgroundTable" st-sortable="postfooter" > + <tbody> + <tr> + <td> + <table width="600" cellpadding="0" cellspacing="0" border="0" align="center" class="devicewidth"> + <tbody> + <tr> + <td width="100%"> + <table width="600" cellpadding="0" cellspacing="0" border="0" align="center" class="devicewidth"> + <tbody> + <tr> + <td align="center" valign="middle" style="font-family: Helvetica, arial, sans-serif; font-size: 14px;color: #ffffff" st-content="postfooter"> + Please ignore this email if you did not initiate the process. + </td> + </tr> + <!-- Spacing --> + <tr> + <td width="100%" height="20"></td> + </tr> + <!-- Spacing --> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> +</table> +<!-- End of postfooter --> + + </body> + </html> + diff --git a/themes/triangles/server/src/views/already-logged-in.pug b/themes/triangles/server/src/views/already-logged-in.pug new file mode 100644 index 00000000..137bbea3 --- /dev/null +++ b/themes/triangles/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 <b>#{ username }</b>.<br/><br/> + | If you are not redirected in few seconds, click <a href="#{ redirection_url }">here</a>.<br/><br/> + | Otherwise, click <a href="#{ logout_endpoint }">here</a> to log off. + else + p You are already logged in as <b>#{ username }</b>.<br/><br/> + | Click <a href="#{ logout_endpoint }">here</a> to log off. diff --git a/themes/triangles/server/src/views/errors/.directory b/themes/triangles/server/src/views/errors/.directory new file mode 100644 index 00000000..33f71bea --- /dev/null +++ b/themes/triangles/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/triangles/server/src/views/errors/401.pug b/themes/triangles/server/src/views/errors/401.pug new file mode 100644 index 00000000..b7a222ad --- /dev/null +++ b/themes/triangles/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.<br/><br/> + | Please click <a href=#{redirection_url}>here</a> 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/triangles/server/src/views/errors/403.pug b/themes/triangles/server/src/views/errors/403.pug new file mode 100644 index 00000000..f4b5ca8a --- /dev/null +++ b/themes/triangles/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.<br/><br/> + | Please click <a href=#{redirection_url}>here</a> if you are not + | redirected in few seconds. + else + p You don't have enough privileges to access this resource. diff --git a/themes/triangles/server/src/views/errors/404.pug b/themes/triangles/server/src/views/errors/404.pug new file mode 100644 index 00000000..06d6375f --- /dev/null +++ b/themes/triangles/server/src/views/errors/404.pug @@ -0,0 +1,11 @@ +extends ../layout/layout.pug + +block variables + - page_classname = "error-404"; + +block form-header + <h1>Error 404</h1> + +block content + img(class="header-img" src="/img/warning.png" alt="warning") + p Page not found. diff --git a/themes/triangles/server/src/views/firstfactor.pug b/themes/triangles/server/src/views/firstfactor.pug new file mode 100644 index 00000000..57447071 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/views/layout/layout.pug b/themes/triangles/server/src/views/layout/layout.pug new file mode 100644 index 00000000..43247436 --- /dev/null +++ b/themes/triangles/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 <a class="authelia-brand" href="https://github.com/clems4ever/authelia">Authelia</a> + block entrypoint + script(src="/js/authelia.js", type="text/javascript") diff --git a/themes/triangles/server/src/views/need-identity-validation.pug b/themes/triangles/server/src/views/need-identity-validation.pug new file mode 100644 index 00000000..4cfd6271 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/views/password-reset-form.pug b/themes/triangles/server/src/views/password-reset-form.pug new file mode 100644 index 00000000..fd931189 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/views/password-reset-request.pug b/themes/triangles/server/src/views/password-reset-request.pug new file mode 100644 index 00000000..855b5998 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/views/secondfactor.pug b/themes/triangles/server/src/views/secondfactor.pug new file mode 100644 index 00000000..87b57818 --- /dev/null +++ b/themes/triangles/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 <b>#{username}</b> + div(class="row") + div(class="u2f-token") + img(src="/img/pendrive.png", alt="security key") + p + | Please, touch your <a href="https://www.yubico.com/products/yubikey-hardware/fido-u2f-security-key/">security key</a><br/> + b Or<br/> + | 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/triangles/server/src/views/totp-register.pug b/themes/triangles/server/src/views/totp-register.pug new file mode 100644 index 00000000..1b4d9835 --- /dev/null +++ b/themes/triangles/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/triangles/server/src/views/u2f-register.pug b/themes/triangles/server/src/views/u2f-register.pug new file mode 100644 index 00000000..d52eba6c --- /dev/null +++ b/themes/triangles/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/triangles/server/test/requests.ts b/themes/triangles/server/test/requests.ts new file mode 100644 index 00000000..93fa0de4 --- /dev/null +++ b/themes/triangles/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<TRequest extends Request, + TOptions extends CoreOptions, + TUriUrlOptions> { + getAsync(uri: string, options?: RequiredUriUrl): BluebirdPromise<RequestResponse>; + getAsync(uri: string): BluebirdPromise<RequestResponse>; + getAsync(options: RequiredUriUrl & CoreOptions): BluebirdPromise<RequestResponse>; + + postAsync(uri: string, options?: CoreOptions): BluebirdPromise<RequestResponse>; + postAsync(uri: string): BluebirdPromise<RequestResponse>; + postAsync(options: RequiredUriUrl & CoreOptions): BluebirdPromise<RequestResponse>; + } +} + +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/triangles/server/tsconfig.json b/themes/triangles/server/tsconfig.json new file mode 100644 index 00000000..ebe98c5e --- /dev/null +++ b/themes/triangles/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/triangles/server/tslint.json b/themes/triangles/server/tslint.json new file mode 100644 index 00000000..c2c1b750 --- /dev/null +++ b/themes/triangles/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/triangles/server/types/.directory b/themes/triangles/server/types/.directory new file mode 100644 index 00000000..1e65000e --- /dev/null +++ b/themes/triangles/server/types/.directory @@ -0,0 +1,4 @@ +[Dolphin] +Timestamp=2018,12,17,20,58,27 +Version=3 +ViewMode=1 diff --git a/themes/triangles/server/types/AuthenticationSession.ts b/themes/triangles/server/types/AuthenticationSession.ts new file mode 100644 index 00000000..bbed0e71 --- /dev/null +++ b/themes/triangles/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/triangles/server/types/Dependencies.ts b/themes/triangles/server/types/Dependencies.ts new file mode 100644 index 00000000..f20404db --- /dev/null +++ b/themes/triangles/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/triangles/server/types/Identity.ts b/themes/triangles/server/types/Identity.ts new file mode 100644 index 00000000..e985984e --- /dev/null +++ b/themes/triangles/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/triangles/server/types/TOTPSecret.ts b/themes/triangles/server/types/TOTPSecret.ts new file mode 100644 index 00000000..d6775f2f --- /dev/null +++ b/themes/triangles/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/triangles/server/types/U2FRegistration.ts b/themes/triangles/server/types/U2FRegistration.ts new file mode 100644 index 00000000..b6080af0 --- /dev/null +++ b/themes/triangles/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/triangles/server/types/dovehash.d.ts b/themes/triangles/server/types/dovehash.d.ts new file mode 100644 index 00000000..c354609c --- /dev/null +++ b/themes/triangles/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/triangles/server/types/speakeasy.d.ts b/themes/triangles/server/types/speakeasy.d.ts new file mode 100644 index 00000000..6ea06948 --- /dev/null +++ b/themes/triangles/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