diff options
27 files changed, 839 insertions, 94 deletions
diff --git a/service/pixelated/adapter/welcome_mail.py b/service/pixelated/adapter/welcome_mail.py new file mode 100644 index 00000000..a40e44a4 --- /dev/null +++ b/service/pixelated/adapter/welcome_mail.py @@ -0,0 +1,28 @@ +# +# Copyright (c) 2015 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +import pkg_resources +from email import message_from_file +from pixelated.adapter.model.mail import InputMail + + +def add_welcome_mail(mail_store): + welcome_mail = pkg_resources.resource_filename('pixelated.assets', 'welcome.mail') + + with open(welcome_mail) as mail_template_file: + mail_template = message_from_file(mail_template_file) + + input_mail = InputMail.from_python_mail(mail_template) + mail_store.add_mail('INBOX', input_mail.raw) diff --git a/service/pixelated/application.py b/service/pixelated/application.py index aa0db132..73d978da 100644 --- a/service/pixelated/application.py +++ b/service/pixelated/application.py @@ -14,37 +14,36 @@ # You should have received a copy of the GNU Affero General Public License # along with Pixelated. If not, see <http://www.gnu.org/licenses/>. -import os +import logging -from email import message_from_file -from twisted.internet import reactor -from twisted.internet import defer -from twisted.internet import ssl from OpenSSL import SSL from OpenSSL import crypto +from leap.common.events import (server as events_server, + register, catalog as events) +from twisted.cred import portal +from twisted.cred.checkers import AllowAnonymousAccess +from twisted.internet import defer +from twisted.internet import reactor +from twisted.internet import ssl -from pixelated.adapter.model.mail import InputMail +from pixelated.adapter.welcome_mail import add_welcome_mail from pixelated.config import arguments -from pixelated.config.services import Services -from pixelated.config.leap import initialize_leap from pixelated.config import logger +from pixelated.config.leap import initialize_leap_single_user, init_monkeypatches, initialize_leap_provider +from pixelated.config.services import Services from pixelated.config.site import PixelatedSite +from pixelated.resources.auth import LeapPasswordChecker, PixelatedRealm, PixelatedAuthSessionWrapper, SessionChecker +from pixelated.resources.login_resource import LoginResource from pixelated.resources.root_resource import RootResource -from leap.common.events import ( - register, - catalog as events -) - -import logging - log = logging.getLogger(__name__) class ServicesFactory(object): - def __init__(self): + def __init__(self, mode): self._services_by_user = {} + self.mode = mode def is_logged_in(self, user_id): return user_id in self._services_by_user @@ -62,11 +61,28 @@ class ServicesFactory(object): self._services_by_user[user_id] = services +class SingleUserServicesFactory(object): + def __init__(self, mode): + self._services = None + self.mode = mode + + def add_session(self, user_id, services): + self._services = services + + def services(self, user_id): + return self._services + + +class UserAgentMode(object): + def __init__(self, is_single_user): + self.is_single_user = is_single_user + + @defer.inlineCallbacks -def start_user_agent(root_resource, services_factory, leap_home, leap_session): +def start_user_agent_in_single_user_mode(root_resource, services_factory, leap_home, leap_session): log.info('Bootstrap done, loading services for user %s' % leap_session.user_auth.username) - services = Services(leap_home, leap_session) + services = Services(leap_session) yield services.setup() if leap_session.fresh_account: @@ -77,7 +93,7 @@ def start_user_agent(root_resource, services_factory, leap_home, leap_session): root_resource.initialize() # soledad needs lots of threads - reactor.threadpool.adjustPoolsize(5, 15) + reactor.getThreadPool().adjustPoolsize(5, 15) log.info('Done, the user agent is ready to be used') @@ -96,27 +112,21 @@ def _ssl_options(sslkey, sslcert): return options +def _create_service_factory(args): + if args.single_user: + return SingleUserServicesFactory(UserAgentMode(is_single_user=True)) + else: + return ServicesFactory(UserAgentMode(is_single_user=False)) + + def initialize(): log.info('Starting the Pixelated user agent') args = arguments.parse_user_agent_args() logger.init(debug=args.debug) - services_factory = ServicesFactory() + services_factory = _create_service_factory(args) resource = RootResource(services_factory) - start_site(args, resource) - - deferred = initialize_leap(args.leap_provider_cert, - args.leap_provider_cert_fingerprint, - args.credentials_file, - args.organization_mode, - args.leap_home) - - deferred.addCallback( - lambda leap_session: start_user_agent( - resource, - services_factory, - args.leap_home, - leap_session)) + deferred = _start_mode(args, resource, services_factory) def _quit_on_error(failure): failure.printTraceback() @@ -129,23 +139,66 @@ def initialize(): deferred.addCallback(_register_shutdown_on_token_expire) deferred.addErrback(_quit_on_error) + log.info('Running the reactor') + reactor.run() +def _start_mode(args, resource, services_factory): + if services_factory.mode.is_single_user: + deferred = _start_in_single_user_mode(args, resource, services_factory) + else: + deferred = _start_in_multi_user_mode(args, resource, services_factory) + return deferred + + +def _start_in_multi_user_mode(args, root_resource, services_factory): + if args.provider is None: + raise ValueError('provider name is required') + + init_monkeypatches() + events_server.ensure_server() + + config, provider = initialize_leap_provider(args.provider, args.leap_provider_cert, args.leap_provider_cert_fingerprint, args.leap_home) + + checker = LeapPasswordChecker(args, provider) + session_checker = SessionChecker() + anonymous_resource = LoginResource(services_factory) + + realm = PixelatedRealm(root_resource, anonymous_resource) + _portal = portal.Portal(realm, [checker, session_checker, AllowAnonymousAccess()]) + + protected_resource = PixelatedAuthSessionWrapper(_portal, root_resource, anonymous_resource, []) + anonymous_resource.set_portal(_portal) + + start_site(args, protected_resource) + + root_resource.initialize(_portal) + reactor.getThreadPool().adjustPoolsize(5, 15) + + return defer.succeed(None) + + +def _start_in_single_user_mode(args, resource, services_factory): + start_site(args, resource) + deferred = initialize_leap_single_user(args.leap_provider_cert, + args.leap_provider_cert_fingerprint, + args.credentials_file, + args.organization_mode, + args.leap_home) + deferred.addCallback( + lambda leap_session: start_user_agent_in_single_user_mode( + resource, + services_factory, + args.leap_home, + leap_session)) + return deferred + + def start_site(config, resource): - log.info('Starting the API with the loading screen on port %s' % config.port) + log.info('Starting the API on port %s' % config.port) if config.sslkey and config.sslcert: reactor.listenSSL(config.port, PixelatedSite(resource), _ssl_options(config.sslkey, config.sslcert), interface=config.host) else: reactor.listenTCP(config.port, PixelatedSite(resource), interface=config.host) - - -def add_welcome_mail(mail_store): - current_path = os.path.dirname(os.path.abspath(__file__)) - welcome_mail = os.path.join(current_path, 'assets', 'welcome.mail') - with open(welcome_mail) as mail_template_file: - mail_template = message_from_file(mail_template_file) - - input_mail = InputMail.from_python_mail(mail_template) - mail_store.add_mail('INBOX', input_mail.raw) diff --git a/service/pixelated/assets/favicon.png b/service/pixelated/assets/favicon.png Binary files differnew file mode 100644 index 00000000..e14841c7 --- /dev/null +++ b/service/pixelated/assets/favicon.png diff --git a/service/pixelated/assets/hive-bg.png b/service/pixelated/assets/hive-bg.png Binary files differnew file mode 100644 index 00000000..77316967 --- /dev/null +++ b/service/pixelated/assets/hive-bg.png diff --git a/service/pixelated/assets/login.html b/service/pixelated/assets/login.html new file mode 100644 index 00000000..734fffd0 --- /dev/null +++ b/service/pixelated/assets/login.html @@ -0,0 +1,32 @@ +<!DOCTYPE html> +<html> +<head> + <title>Pixelated - Login</title> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <link rel="icon" type="image/png" href="/startup-assets/favicon.png"> + <link rel="stylesheet" type="text/css" href="/startup-assets/normalize.min.css"> + <link rel="stylesheet" type="text/css" href="/startup-assets/pixelated.css"> + <link rel="stylesheet" type="text/css" href="/startup-assets/opensans.css"> +</head> +<body> +<div class="content"> + <div class="login"> + + <img class="logo" src="/startup-assets/pixelated-logo-orange.svg" alt="Pixelated logo" /> + + + <form class="standard" id="login_form" action="/login" method="post"> + <input type="text" name="username" id="email" class="text-field" placeholder=" username" tabindex="1" autofocus> + <input type="password" name="password" id="password" class="text-field" placeholder=" password" tabindex="2" autocomplete="off"> + + <input type="submit" name="login" value="Login" class="button" tabindex="3"> + + </form> + </div> + <div class="disclaimer"> + Some disclaimer + </div> +</div> +</body> + +</html> diff --git a/service/pixelated/assets/normalize.min.css b/service/pixelated/assets/normalize.min.css new file mode 100644 index 00000000..d3c7f4d5 --- /dev/null +++ b/service/pixelated/assets/normalize.min.css @@ -0,0 +1 @@ +/*! normalize.css v3.0.1 | MIT License | git.io/normalize */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,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:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}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{border:0;padding:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{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-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:bold}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}
\ No newline at end of file diff --git a/service/pixelated/assets/opensans.css b/service/pixelated/assets/opensans.css new file mode 100644 index 00000000..a42f346c --- /dev/null +++ b/service/pixelated/assets/opensans.css @@ -0,0 +1,69 @@ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + src: local("Open Sans Light"), local("OpenSans-Light"), url("/fonts/OpenSans-Light.woff") format("woff"); +} + +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + src: local("Open Sans"), local("OpenSans"), url("/fonts/OpenSans.woff") format("woff"); +} + +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + src: local("Open Sans Semibold"), local("OpenSans-Semibold"), url("/fonts/OpenSans-Semibold.woff") format("woff"); +} + +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 700; + src: local("Open Sans Bold"), local("OpenSans-Bold"), url("/fonts/OpenSans-Bold.woff") format("woff"); +} + +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 800; + src: local("Open Sans Extrabold"), local("OpenSans-Extrabold"), url("/fonts/OpenSans-Extrabold.woff") format("woff"); +} + +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + src: local("Open Sans Light Italic"), local("OpenSansLight-Italic"), url("/fonts/OpenSansLight-Italic.woff") format("woff"); +} + +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + src: local("Open Sans Italic"), local("OpenSans-Italic"), url("/fonts/OpenSans-Italic.woff") format("woff"); +} + +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + src: local("Open Sans Semibold Italic"), local("OpenSans-SemiboldItalic"), url("/fonts/OpenSans-SemiboldItalic.woff ") format("woff"); +} + +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 700; + src: local("Open Sans Bold Italic"), local("OpenSans-BoldItalic"), url("/fonts/OpenSans-BoldItalic.woff") format("woff"); +} + +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 800; + src: local("Open Sans Extrabold Italic"), local("OpenSans-ExtraboldItalic"), url("/fonts/OpenSans-ExtraboldItalic.woff") format("woff"); +} diff --git a/service/pixelated/assets/pixelated-logo-orange.svg b/service/pixelated/assets/pixelated-logo-orange.svg new file mode 100644 index 00000000..7e0ef43d --- /dev/null +++ b/service/pixelated/assets/pixelated-logo-orange.svg @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 16.0.4, 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="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + width="509.707px" height="142.439px" viewBox="0 0 509.707 142.439" enable-background="new 0 0 509.707 142.439" + xml:space="preserve"> +<g> + <path fill="#F9A731" d="M0,35.469v71.365l62.837,35.605l62.833-35.605V35.469L62.813,0L0,35.469z M60.262,116.617L23.735,96.332 + V52.46l36.586,20.999L60.262,116.617z M101.936,96.332l-36.148,20.285l0.067-43.123l36.081-21.034V96.332z M101.936,46.44 + L62.951,69.553L23.733,46.44l39.218-21.131L101.936,46.44z"/> + <path fill="#F9A731" d="M169.505,42.332h-19.968v59.328h13.52V79.655h6.448c11.579,0,20.279-6.832,20.279-19.056 + C189.784,48.302,181.084,42.332,169.505,42.332z M166.866,68.868h-3.809v-15.75h3.809c5.323,0,10.357,1.798,10.357,7.91 + C177.224,67.07,172.189,68.868,166.866,68.868z"/> + <rect x="194.309" y="42.332" fill="#F9A731" width="13.52" height="59.328"/> + <polygon fill="#F9A731" points="266.516,42.332 249.689,42.332 238.759,58.514 227.827,42.332 211.721,42.332 230.417,69.73 + 210.228,101.66 226.982,101.66 238.759,81.453 250.534,101.66 268.01,101.66 247.099,69.73 "/> + <polygon fill="#F9A731" points="270.128,101.66 304.069,101.66 304.069,89.795 283.647,89.795 283.647,77.857 303.207,77.857 + 303.207,65.991 283.647,65.991 283.647,54.199 304.069,54.199 304.069,42.332 270.128,42.332 "/> + <path fill="#F9A731" d="M354.807,42.332l-19.156,47.463H322.33V42.332h-13.52v59.328h22.053h11.888h2.636l4.386-11.865h22.578 + l4.391,11.865h14.524l-23.944-59.328H354.807z M354.377,77.928l6.614-17.257h0.145l6.615,17.257H354.377z"/> + <polygon fill="#F9A731" points="379.939,54.199 394.073,54.199 394.073,101.66 407.592,101.66 407.592,54.199 421.687,54.199 + 421.687,42.332 379.939,42.332 "/> + <polygon fill="#F9A731" points="426.265,101.66 460.207,101.66 460.207,89.795 439.785,89.795 439.785,77.857 459.344,77.857 + 459.344,65.991 439.785,65.991 439.785,54.199 460.207,54.199 460.207,42.332 426.265,42.332 "/> + <path fill="#F9A731" d="M479.792,42.332h-14.94v59.328h14.94c16.324,0,29.914-12.37,29.914-29.699 + C509.707,54.701,496.044,42.332,479.792,42.332z M480.457,89.577h-2.084V54.414h2.084c10.067,0,16.9,7.695,16.9,17.619 + C497.285,81.955,490.455,89.577,480.457,89.577z"/> +</g> +</svg> diff --git a/service/pixelated/assets/pixelated.css b/service/pixelated/assets/pixelated.css new file mode 100644 index 00000000..b435b828 --- /dev/null +++ b/service/pixelated/assets/pixelated.css @@ -0,0 +1,127 @@ +body { + font-family: "Open Sans", "Microsoft YaHei", "Hiragino Sans GB", "Hiragino Sans GB W3", "微软雅黑", "Helvetica Neue", Arial, sans-serif; + background-color: #EAEAEA; + height: 100vh; + color: #3E3A37; + + background-image: url("hive-bg.png"); + background-repeat: repeat; +} + +.content { + height: 100vh; + width: 100%; +} + +.error { + color: #D72A25; + margin: 10px 0 0 0; +} + +.message-panel { + width: 100%; + margin: 10px auto; + z-index: 10000; + text-align: center; + } + +.message-panel span{ + background: #F7E8AF; + padding: 5px 60px; + border: 1px solid #f2db81; + color: #987b0f; + box-shadow: 1px 1px 3px #69560b; +} + +.message-panel.message-panel-small span{ + padding: 5px 0px; + display: inline-block; + width: 100%; +} + +.login { + display: block; + width: 240px; + margin: auto; + padding: 45px 40px 35px 40px; + background-color: #FFF; + margin-top: 2%; + margin-bottom: 2%; +} + +form#login_form { + padding: 10px 0; +} + +.disclaimer { + display: block; + margin-top: 10%; + width: 50%; + margin: auto; + background-color: #2BA6CB; + color: #FFFFFF; + font-weight: 300; + font-size: 0.8rem; + padding: 1em; + margin-bottom: 20px; +} + +.disclaimer li { + margin-top: 1em; +} + +.logo { + width: 100%; + height: auto; +} + +input { + display: block; + margin: 10px 0; +} + +input.text-field { + width: 97%; +} + +button, .button, input[type=button] { + cursor: pointer; + margin: 0 0 1.25rem; + border: none; + position: relative; + text-decoration: none; + text-align: center; + -webkit-appearance: none; + display: inline-block; + padding: 0.4rem 1.1rem; + font-size: 0.9rem; + background-color: #2ba6cb; + border-color: #2285a2; + color: white; + -webkit-border-radius: 2px; + -moz-border-radius: 2px; + -ms-border-radius: 2px; + -o-border-radius: 2px; + border-radius: 2px; +} + +button:hover, button:focus, .button:hover, .button:focus, input[type=button]:hover, input[type=button]:focus { + background-color: #2285a2; + outline: none; + color: white; +} + +ul.accounts { + margin-bottom: 5%; +} + +ul.accounts li { + display: inline-block; + list-style: none; + margin-right: 35px; + margin-top: 0px; +} + +ul.accounts li span { + font-weight: bold; +} diff --git a/service/pixelated/config/arguments.py b/service/pixelated/config/arguments.py index 87484b9b..d8a18c16 100644 --- a/service/pixelated/config/arguments.py +++ b/service/pixelated/config/arguments.py @@ -28,8 +28,11 @@ def parse_user_agent_args(): parser.add_argument('--port', type=int, default=3333, help='the port to run the user agent on') parser.add_argument('-sk', '--sslkey', metavar='<server.key>', default=None, help='use specified file as web server\'s SSL key (when using the user-agent together with the pixelated-dispatcher)') parser.add_argument('-sc', '--sslcert', metavar='<server.crt>', default=None, help='use specified file as web server\'s SSL certificate (when using the user-agent together with the pixelated-dispatcher)') + parser.add_argument('--multi-user', help='Run user agent in multi user mode', action='store_false', default=True, dest='single_user') + parser.add_argument('-p', '--provider', help='specify a provider for mutli-user mode', metavar='<provider host>', default=None, dest='provider') args = parser.parse_args() + return args diff --git a/service/pixelated/config/credentials.py b/service/pixelated/config/credentials.py index ae1bc4f3..a6da86e6 100644 --- a/service/pixelated/config/credentials.py +++ b/service/pixelated/config/credentials.py @@ -50,5 +50,4 @@ def read_from_file(credentials_file): def read_from_dispatcher(): config = json.loads(sys.stdin.read()) - return config['leap_provider_hostname'], config['user'], config['password'] diff --git a/service/pixelated/config/leap.py b/service/pixelated/config/leap.py index 00723224..b8e5f50d 100644 --- a/service/pixelated/config/leap.py +++ b/service/pixelated/config/leap.py @@ -10,28 +10,61 @@ from twisted.internet import defer import os import logging +import logging +log = logging.getLogger(__name__) + + +def initialize_leap_provider(provider_hostname, provider_cert, provider_fingerprint, leap_home): + LeapCertificate.set_cert_and_fingerprint(provider_cert, + provider_fingerprint) + + config = LeapConfig(leap_home=leap_home, start_background_jobs=True) + provider = LeapProvider(provider_hostname, config) + LeapCertificate(provider).setup_ca_bundle() + + return config, provider + @defer.inlineCallbacks -def initialize_leap(leap_provider_cert, - leap_provider_cert_fingerprint, - credentials_file, - organization_mode, - leap_home, - initial_sync=True): +def initialize_leap_multi_user(provider_hostname, + leap_provider_cert, + leap_provider_cert_fingerprint, + credentials_file, + organization_mode, + leap_home): + + config, provider = initialize_leap_provider(provider_hostname, leap_provider_cert, leap_provider_cert_fingerprint, leap_home) + + defer.returnValue((config, provider)) + + +@defer.inlineCallbacks +def authenticate_user(provider, username, password, initial_sync=True): + leap_session = LeapSessionFactory(provider).create(username, password) + + if initial_sync: + yield leap_session.initial_sync() + + defer.returnValue(leap_session) + + +@defer.inlineCallbacks +def initialize_leap_single_user(leap_provider_cert, + leap_provider_cert_fingerprint, + credentials_file, + organization_mode, + leap_home, + initial_sync=True): + init_monkeypatches() events_server.ensure_server() + provider, username, password = credentials.read(organization_mode, credentials_file) - LeapCertificate.set_cert_and_fingerprint(leap_provider_cert, - leap_provider_cert_fingerprint) - config = LeapConfig(leap_home=leap_home, start_background_jobs=True) - provider = LeapProvider(provider, config) - LeapCertificate(provider).setup_ca_bundle() - leap_session = LeapSessionFactory(provider).create(username, password) + config, provider = initialize_leap_provider(provider, leap_provider_cert, leap_provider_cert_fingerprint, leap_home) - if initial_sync: - leap_session = yield leap_session.initial_sync() + leap_session = yield authenticate_user(provider, username, password) defer.returnValue(leap_session) diff --git a/service/pixelated/config/services.py b/service/pixelated/config/services.py index 1d5d951a..3f254571 100644 --- a/service/pixelated/config/services.py +++ b/service/pixelated/config/services.py @@ -18,8 +18,8 @@ logger = logging.getLogger(__name__) class Services(object): - def __init__(self, leap_home, leap_session): - self._leap_home = leap_home + def __init__(self, leap_session): + self._leap_home = leap_session.config.leap_home self._leap_session = leap_session @defer.inlineCallbacks diff --git a/service/pixelated/maintenance.py b/service/pixelated/maintenance.py index 4562717f..5e1edac3 100644 --- a/service/pixelated/maintenance.py +++ b/service/pixelated/maintenance.py @@ -20,7 +20,7 @@ import random from twisted.internet import reactor, defer from twisted.internet.threads import deferToThread from pixelated.adapter.mailstore.maintenance import SoledadMaintenance -from pixelated.config.leap import initialize_leap +from pixelated.config.leap import initialize_leap_single_user from pixelated.config import logger, arguments import logging @@ -39,7 +39,7 @@ def initialize(): @defer.inlineCallbacks def _run(): - leap_session = yield initialize_leap( + leap_session = yield initialize_leap_single_user( args.leap_provider_cert, args.leap_provider_cert_fingerprint, args.credentials_file, diff --git a/service/pixelated/resources/__init__.py b/service/pixelated/resources/__init__.py index 3d81d784..9cde015f 100644 --- a/service/pixelated/resources/__init__.py +++ b/service/pixelated/resources/__init__.py @@ -16,8 +16,12 @@ import json +from twisted.web._responses import UNAUTHORIZED from twisted.web.resource import Resource +# from pixelated.resources.login_resource import LoginResource +from pixelated.resources.session import IPixelatedSession + class SetEncoder(json.JSONEncoder): def default(self, obj): @@ -48,8 +52,19 @@ class BaseResource(Resource): self._services_factory = services_factory def _get_user_id_from_request(self, request): - # currently we are faking this - return self._services_factory._services_by_user.keys()[0] + if self._services_factory.mode.is_single_user: + return None # it doesn't matter + session = self.get_session(request) + if session.is_logged_in(): + return session.user_uuid + raise ValueError('Not logged in') + + def is_logged_in(self, request): + session = self.get_session(request) + return session.is_logged_in() + + def get_session(self, request): + return IPixelatedSession(request.getSession()) def _services(self, request): user_id = self._get_user_id_from_request(request) @@ -72,3 +87,13 @@ class BaseResource(Resource): def feedback_service(self, request): return self._service(request, 'feedback_service') + + +class UnAuthorizedResource(Resource): + + def __init__(self): + Resource.__init__(self) + + def render_GET(self, request): + request.setResponseCode(UNAUTHORIZED) + return "Unauthorized!" diff --git a/service/pixelated/resources/auth.py b/service/pixelated/resources/auth.py new file mode 100644 index 00000000..7076490d --- /dev/null +++ b/service/pixelated/resources/auth.py @@ -0,0 +1,177 @@ +# +# Copyright (c) 2016 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + +import logging + +from leap.auth import SRPAuth +from leap.exceptions import SRPAuthenticationError +from twisted.cred.checkers import ANONYMOUS +from twisted.cred.credentials import ICredentials +from twisted.cred.error import UnauthorizedLogin +from twisted.internet import defer, threads +from twisted.web._auth.wrapper import UnauthorizedResource +from twisted.web.error import UnsupportedMethod +from zope.interface import implements, implementer, Attribute +from twisted.cred import portal, checkers, credentials +from twisted.web import util +from twisted.cred import error +from twisted.web.resource import IResource, ErrorPage + +from pixelated.adapter.welcome_mail import add_welcome_mail +from pixelated.config.leap import authenticate_user +from pixelated.config.services import Services +from pixelated.resources import IPixelatedSession + + +log = logging.getLogger(__name__) + + +@implementer(checkers.ICredentialsChecker) +class LeapPasswordChecker(object): + credentialInterfaces = ( + credentials.IUsernamePassword, + credentials.IUsernameHashedPassword + ) + + def __init__(self, setup_args, leap_provider): + self._setup_args = setup_args + self._leap_provider = leap_provider + + def requestAvatarId(self, credentials): + def _validate_credentials(): + try: + srp_auth = SRPAuth(self._leap_provider.api_uri, self._leap_provider.local_ca_crt) + srp_auth.authenticate(credentials.username, credentials.password) + except SRPAuthenticationError: + raise UnauthorizedLogin() + + def _authententicate_user(_): + return authenticate_user(self._leap_provider, credentials.username, credentials.password) + + d = threads.deferToThread(_validate_credentials) + d.addCallback(_authententicate_user) + return d + + +class ISessionCredential(ICredentials): + + request = Attribute('the current request') + + +@implementer(ISessionCredential) +class SessionCredential(object): + def __init__(self, request): + self.request = request + + +@implementer(checkers.ICredentialsChecker) +class SessionChecker(object): + credentialInterfaces = (ISessionCredential,) + + def requestAvatarId(self, credentials): + session = self.get_session(credentials.request) + if session.is_logged_in(): + return defer.succeed(session.user_uuid) + else: + return defer.succeed(ANONYMOUS) + + def get_session(self, request): + return IPixelatedSession(request.getSession()) + + +class LeapUser(object): + + def __init__(self, leap_session): + self._leap_session = leap_session + + @defer.inlineCallbacks + def start_services(self, services_factory): + services = Services(self._leap_session) + yield services.setup() + + if self._leap_session.fresh_account: + yield add_welcome_mail(self._leap_session.mail_store) + + services_factory.add_session(self._leap_session.user_auth.uuid, services) + + def init_http_session(self, request): + session = IPixelatedSession(request.getSession()) + session.user_uuid = self._leap_session.user_auth.uuid + + +class PixelatedRealm(object): + implements(portal.IRealm) + + def __init__(self, root_resource, anonymous_resource): + self._root_resource = root_resource + self._anonymous_resource = anonymous_resource + + def requestAvatar(self, avatarId, mind, *interfaces): + if IResource in interfaces: + if avatarId == checkers.ANONYMOUS: + return IResource, checkers.ANONYMOUS, lambda: None + else: + leap_session = avatarId + user = LeapUser(leap_session) + return IResource, user, lambda: None + raise NotImplementedError() + + +@implementer(IResource) +class PixelatedAuthSessionWrapper(object): + + isLeaf = False + + def __init__(self, portal, root_resource, anonymous_resource, credentialFactories): + self._portal = portal + self._credentialFactories = credentialFactories + self._root_resource = root_resource + self._anonymous_resource = anonymous_resource + + def render(self, request): + raise UnsupportedMethod(()) + + def getChildWithDefault(self, path, request): + request.postpath.insert(0, request.prepath.pop()) + + return self._authorizedResource(request) + + def _authorizedResource(self, request): + creds = SessionCredential(request) + return util.DeferredResource(self._login(creds)) + + def _login(self, credentials): + d = self._portal.login(credentials, None, IResource) + d.addCallbacks(self._loginSucceeded, self._loginFailed) + return d + + def _loginSucceeded(self, args): + interface, avatar, logout = args + + if avatar == checkers.ANONYMOUS: + return self._anonymous_resource + else: + return self._root_resource + + def _loginFailed(self, result): + if result.check(error.Unauthorized, error.LoginFailed): + return UnauthorizedResource(self._credentialFactories) + else: + log.err( + result, + "HTTPAuthSessionWrapper.getChildWithDefault encountered " + "unexpected error") + return ErrorPage(500, None, None) diff --git a/service/pixelated/resources/login_resource.py b/service/pixelated/resources/login_resource.py new file mode 100644 index 00000000..b0e8ac3b --- /dev/null +++ b/service/pixelated/resources/login_resource.py @@ -0,0 +1,105 @@ +# +# Copyright (c) 2016 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + +import logging +import os +from string import Template + +from twisted.cred import credentials +from twisted.internet import defer +from twisted.web.resource import IResource +from twisted.web.server import NOT_DONE_YET +from twisted.web.static import File + +from pixelated.resources import BaseResource, UnAuthorizedResource + +log = logging.getLogger(__name__) + + +class LoginResource(BaseResource): + + def __init__(self, services_factory, portal=None): + BaseResource.__init__(self, services_factory) + self._static_folder = self._get_static_folder() + self._startup_folder = self._get_startup_folder() + self._html_template = open(os.path.join(self._startup_folder, 'login.html')).read() + self._portal = portal + self.putChild('startup-assets', File(self._startup_folder)) + + def set_portal(self, portal): + self._portal = portal + + def getChild(self, path, request): + if path == '': + return self + if path == 'login': + return self + return UnAuthorizedResource() + + def render_GET(self, request): + response = Template(self._html_template).safe_substitute() + return str(response) + + def render_POST(self, request): + + def render_response(response): + request.redirect("/") + request.finish() + + def render_error(error): + login_form = self.render_GET(request) + request.status = 500 + request.write('We got an error:\n') + request.write(str(error)) + request.write(login_form) + request.finish() + + d = self._handle_login(request) + d.addCallbacks(render_response, render_error) + + return NOT_DONE_YET + + @defer.inlineCallbacks + def _handle_login(self, request): + if self.is_logged_in(request): + defer.succeed(None) + return + username = request.args['username'][0] + password = request.args['password'][0] + creds = credentials.UsernamePassword(username, password) + + iface, leap_user, logout = yield self._portal.login(creds, None, IResource) + + # we should really check whether the response is anonymous + + yield leap_user.start_services(self._services_factory) + leap_user.init_http_session(request) + + log.info('about to redirect to home page') + + def _get_startup_folder(self): + path = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(path, '..', 'assets') + + def _get_static_folder(self): + static_folder = os.path.abspath(os.path.join(os.path.abspath(__file__), "..", "..", "..", "web-ui", "app")) + # this is a workaround for packaging + if not os.path.exists(static_folder): + static_folder = os.path.abspath( + os.path.join(os.path.abspath(__file__), "..", "..", "..", "..", "web-ui", "app")) + if not os.path.exists(static_folder): + static_folder = os.path.join('/', 'usr', 'share', 'pixelated-user-agent') + return static_folder diff --git a/service/pixelated/resources/root_resource.py b/service/pixelated/resources/root_resource.py index 0894444b..a1ed876e 100644 --- a/service/pixelated/resources/root_resource.py +++ b/service/pixelated/resources/root_resource.py @@ -1,5 +1,20 @@ +# +# Copyright (c) 2016 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + import os -import requests from string import Template from pixelated.resources import BaseResource @@ -7,6 +22,7 @@ from pixelated.resources.attachments_resource import AttachmentsResource from pixelated.resources.contacts_resource import ContactsResource from pixelated.resources.features_resource import FeaturesResource from pixelated.resources.feedback_resource import FeedbackResource +from pixelated.resources.login_resource import LoginResource from pixelated.resources.user_settings_resource import UserSettingsResource from pixelated.resources.mail_resource import MailResource from pixelated.resources.mails_resource import MailsResource @@ -39,7 +55,7 @@ class RootResource(BaseResource): return self return Resource.getChild(self, path, request) - def initialize(self): + def initialize(self, portal=None): self.putChild('assets', File(self._static_folder)) self.putChild('keys', KeysResource(self._services_factory)) self.putChild(AttachmentsResource.BASE_URL, AttachmentsResource(self._services_factory)) @@ -50,6 +66,7 @@ class RootResource(BaseResource): self.putChild('mail', MailResource(self._services_factory)) self.putChild('feedback', FeedbackResource(self._services_factory)) self.putChild('user-settings', UserSettingsResource(self._services_factory)) + self.putChild('login', LoginResource(self._services_factory, portal)) self._mode = MODE_RUNNING diff --git a/service/pixelated/resources/session.py b/service/pixelated/resources/session.py new file mode 100644 index 00000000..76b54901 --- /dev/null +++ b/service/pixelated/resources/session.py @@ -0,0 +1,36 @@ +# +# Copyright (c) 2016 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + +from zope.interface import Interface, Attribute, implements +from twisted.python.components import registerAdapter +from twisted.web.server import Session + + +class IPixelatedSession(Interface): + user_uuid = Attribute('The uuid of the currently logged in user') + + +class PixelatedSession(object): + implements(IPixelatedSession) + + def __init__(self, session): + self.user_uuid = None + + def is_logged_in(self): + return self.user_uuid is not None + + +registerAdapter(PixelatedSession, Session, IPixelatedSession) diff --git a/service/test/support/integration/app_test_client.py b/service/test/support/integration/app_test_client.py index 1cec62c2..dda13b4f 100644 --- a/service/test/support/integration/app_test_client.py +++ b/service/test/support/integration/app_test_client.py @@ -33,7 +33,7 @@ from twisted.web.resource import getChildForRequest # from twisted.web.server import Site as PixelatedSite from pixelated.adapter.mailstore.leap_attachment_store import LeapAttachmentStore from pixelated.adapter.services.feedback_service import FeedbackService -from pixelated.application import ServicesFactory +from pixelated.application import ServicesFactory, UserAgentMode, SingleUserServicesFactory from pixelated.config.site import PixelatedSite from pixelated.adapter.mailstore import LeapMailStore @@ -85,7 +85,7 @@ class AppTestClient(object): mails = yield self.mail_service.all_mails() self.search_engine.index_mails(mails) - self.service_factory = ServicesFactory() + self.service_factory = SingleUserServicesFactory(UserAgentMode(is_single_user=True)) services = mock() services.keymanager = self.keymanager services.mail_service = self.mail_service diff --git a/service/test/unit/resources/test_attachments_resource.py b/service/test/unit/resources/test_attachments_resource.py index f3d8ce53..96677cf4 100644 --- a/service/test/unit/resources/test_attachments_resource.py +++ b/service/test/unit/resources/test_attachments_resource.py @@ -7,6 +7,7 @@ from mockito import mock, when, verify, any as ANY from twisted.internet import defer from twisted.web.test.requesthelper import DummyRequest +from pixelated.application import UserAgentMode from pixelated.resources.attachments_resource import AttachmentsResource from test.unit.resources import DummySite @@ -17,13 +18,14 @@ class AttachmentsResourceTest(unittest.TestCase): def setUp(self): self.mail_service = mock() - self.servicesFactory = mock() + self.services_factory = mock() + self.services_factory.mode = UserAgentMode(is_single_user=True) self.services = mock() self.services.mail_service = self.mail_service - self.servicesFactory._services_by_user = {'someuserid': self.mail_service} - when(self.servicesFactory).services(ANY()).thenReturn(self.services) + self.services_factory._services_by_user = {'someuserid': self.mail_service} + when(self.services_factory).services(ANY()).thenReturn(self.services) - self.mails_resource = AttachmentsResource(self.servicesFactory) + self.mails_resource = AttachmentsResource(self.services_factory) self.mails_resource.isLeaf = True self.web = DummySite(self.mails_resource) diff --git a/service/test/unit/resources/test_feedback_resource.py b/service/test/unit/resources/test_feedback_resource.py index 28dcabad..56433f56 100644 --- a/service/test/unit/resources/test_feedback_resource.py +++ b/service/test/unit/resources/test_feedback_resource.py @@ -2,6 +2,8 @@ import json from mockito import verify, mock, when, any as ANY from twisted.trial import unittest from twisted.web.test.requesthelper import DummyRequest + +from pixelated.application import UserAgentMode from pixelated.resources.feedback_resource import FeedbackResource from test.unit.resources import DummySite @@ -9,13 +11,14 @@ from test.unit.resources import DummySite class TestFeedbackResource(unittest.TestCase): def setUp(self): self.feedback_service = mock() - self.servicesFactory = mock() + self.services_factory = mock() + self.services_factory.mode = UserAgentMode(is_single_user=True) self.services = mock() self.services.feedback_service = self.feedback_service - self.servicesFactory._services_by_user = {'someuserid': self.feedback_service} - when(self.servicesFactory).services(ANY()).thenReturn(self.services) + self.services_factory._services_by_user = {'someuserid': self.feedback_service} + when(self.services_factory).services(ANY()).thenReturn(self.services) - self.web = DummySite(FeedbackResource(self.servicesFactory)) + self.web = DummySite(FeedbackResource(self.services_factory)) def test_sends_feedback_to_leap_web(self): request = DummyRequest(['/feedback']) diff --git a/service/test/unit/resources/test_keys_resources.py b/service/test/unit/resources/test_keys_resources.py index a737bc16..6aa822e1 100644 --- a/service/test/unit/resources/test_keys_resources.py +++ b/service/test/unit/resources/test_keys_resources.py @@ -3,7 +3,7 @@ import ast from mockito import mock, when, any as ANY from leap.keymanager import OpenPGPKey, KeyNotFound -from pixelated.application import ServicesFactory +from pixelated.application import ServicesFactory, UserAgentMode from pixelated.resources.keys_resource import KeysResource import twisted.trial.unittest as unittest from twisted.web.test.requesthelper import DummyRequest @@ -15,12 +15,13 @@ class TestKeysResource(unittest.TestCase): def setUp(self): self.keymanager = mock() - self.servicesFactory = mock() + self.services_factory = mock() + self.services_factory.mode = UserAgentMode(is_single_user=True) self.services = mock() self.services.keymanager = self.keymanager - self.servicesFactory._services_by_user = {'someuserid': self.keymanager} - when(self.servicesFactory).services(ANY()).thenReturn(self.services) - self.web = DummySite(KeysResource(self.servicesFactory)) + self.services_factory._services_by_user = {'someuserid': self.keymanager} + when(self.services_factory).services(ANY()).thenReturn(self.services) + self.web = DummySite(KeysResource(self.services_factory)) def test_returns_404_if_key_not_found(self): request = DummyRequest(['/keys']) diff --git a/service/test/unit/resources/test_mails_resource.py b/service/test/unit/resources/test_mails_resource.py index e36e4f5f..2d9cb33c 100644 --- a/service/test/unit/resources/test_mails_resource.py +++ b/service/test/unit/resources/test_mails_resource.py @@ -21,6 +21,7 @@ from mockito import mock, when, verify, any as ANY from twisted.internet import defer from twisted.web.test.requesthelper import DummyRequest +from pixelated.application import UserAgentMode from pixelated.resources.mails_resource import MailsResource from test.unit.resources import DummySite @@ -28,12 +29,13 @@ from test.unit.resources import DummySite class TestMailsResource(unittest.TestCase): def setUp(self): self.mail_service = mock() - self.servicesFactory = mock() + self.services_factory = mock() + self.services_factory.mode = UserAgentMode(is_single_user=True) self.services = mock() self.services.mail_service = self.mail_service self.services.draft_service = mock() - self.servicesFactory._services_by_user = {'someuserid': self.mail_service} - when(self.servicesFactory).services(ANY()).thenReturn(self.services) + self.services_factory._services_by_user = {'someuserid': self.mail_service} + when(self.services_factory).services(ANY()).thenReturn(self.services) @patch('leap.common.events.register') def test_render_GET_should_unicode_mails_search_query(self, mock_register): @@ -46,7 +48,7 @@ class TestMailsResource(unittest.TestCase): unicodified_search_term = u'coração' when(self.mail_service).mails(unicodified_search_term, 25, 1).thenReturn(defer.Deferred()) - mails_resource = MailsResource(self.servicesFactory) + mails_resource = MailsResource(self.services_factory) mails_resource.isLeaf = True web = DummySite(mails_resource) d = web.get(request) @@ -66,7 +68,7 @@ class TestMailsResource(unittest.TestCase): when(self.mail_service).attachment('some fake attachment id').thenReturn(defer.Deferred()) request.content = content - mails_resource = MailsResource(self.servicesFactory) + mails_resource = MailsResource(self.services_factory) mails_resource.isLeaf = True web = DummySite(mails_resource) d = web.get(request) @@ -90,7 +92,7 @@ class TestMailsResource(unittest.TestCase): .thenReturn(defer.succeed(as_dictable)) request.content = content - mails_resource = MailsResource(self.servicesFactory) + mails_resource = MailsResource(self.services_factory) mails_resource.isLeaf = True web = DummySite(mails_resource) d = web.get(request) diff --git a/service/test/unit/resources/test_root_resource.py b/service/test/unit/resources/test_root_resource.py index 4a8e1d7e..3b0846ee 100644 --- a/service/test/unit/resources/test_root_resource.py +++ b/service/test/unit/resources/test_root_resource.py @@ -1,6 +1,8 @@ import unittest import re from mockito import mock, when, any as ANY + +from pixelated.application import UserAgentMode from test.unit.resources import DummySite from twisted.web.test.requesthelper import DummyRequest from pixelated.resources.root_resource import RootResource @@ -12,6 +14,7 @@ class TestRootResource(unittest.TestCase): def setUp(self): self.mail_service = mock() self.services_factory = mock() + self.services_factory.mode = UserAgentMode(is_single_user=True) self.services = mock() self.services.mail_service = self.mail_service self.services_factory._services_by_user = {'someuserid': self.mail_service} diff --git a/service/test/unit/test_application.py b/service/test/unit/test_application.py index 7f46d9e9..a0eb9986 100644 --- a/service/test/unit/test_application.py +++ b/service/test/unit/test_application.py @@ -46,10 +46,10 @@ class ApplicationTest(unittest.TestCase): leap_session = MagicMock() config = ApplicationTest.MockConfig(12345, '127.0.0.1', leap_session) - d = pixelated.application.start_user_agent(app_mock, services_factory_mock, config.home, leap_session) + d = pixelated.application.start_user_agent_in_single_user_mode(app_mock, services_factory_mock, config.home, leap_session) def _assert(_): - services_mock.assert_called_once_with(config.home, leap_session) + services_mock.assert_called_once_with(leap_session) d.addCallback(_assert) return d @@ -66,10 +66,10 @@ class ApplicationTest(unittest.TestCase): config = ApplicationTest.MockConfig(12345, '127.0.0.1', sslkey="sslkey", sslcert="sslcert") - d = pixelated.application.start_user_agent(app_mock, services_factory_mock, config.home, leap_session) + d = pixelated.application.start_user_agent_in_single_user_mode(app_mock, services_factory_mock, config.home, leap_session) def _assert(_): - services_mock.assert_called_once_with(config.home, leap_session) + services_mock.assert_called_once_with(leap_session) d.addCallback(_assert) return d diff --git a/service/test/unit/test_welcome_mail.py b/service/test/unit/test_welcome_mail.py index b0162eec..829740d3 100644 --- a/service/test/unit/test_welcome_mail.py +++ b/service/test/unit/test_welcome_mail.py @@ -19,8 +19,8 @@ import unittest from mockito import verify, mock from mockito.matchers import Matcher from email import message_from_file -from pixelated.application import add_welcome_mail from pixelated.adapter.model.mail import InputMail +from pixelated.adapter.welcome_mail import add_welcome_mail class TestWelcomeMail(unittest.TestCase): |