satellite/console: send email when user's account gets locked

We send an email when user's account gets locked.

Issue: https://github.com/storj/storj/issues/4967

Change-Id: I68beceda0ac09128755c0333dfa014bd5a186317
This commit is contained in:
Vitalii 2022-07-14 16:44:06 +03:00 committed by Storj Robot
parent 5a2e348b06
commit ec72adb2a6
12 changed files with 374 additions and 39 deletions

View File

@ -564,6 +564,11 @@ func NewAPI(log *zap.Logger, full *identity.FullIdentity, db DB,
peer.Console.AuthTokens = consoleauth.NewService(config.ConsoleAuth, &consoleauth.Hmac{Secret: []byte(consoleConfig.AuthTokenSecret)})
externalAddress := consoleConfig.ExternalAddress
if externalAddress == "" {
externalAddress = "http://" + peer.Console.Listener.Addr().String()
}
peer.Console.Service, err = console.NewService(
peer.Log.Named("console:service"),
peer.DB.Console(),
@ -576,6 +581,8 @@ func NewAPI(log *zap.Logger, full *identity.FullIdentity, db DB,
peer.Payments.DepositWallets,
peer.Analytics.Service,
peer.Console.AuthTokens,
peer.Mail.Service,
externalAddress,
consoleConfig.Config,
)
if err != nil {

View File

@ -20,7 +20,6 @@ import (
"storj.io/storj/private/web"
"storj.io/storj/satellite/analytics"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/console/consoleweb/consoleql"
"storj.io/storj/satellite/console/consoleweb/consolewebauth"
"storj.io/storj/satellite/mailservice"
"storj.io/storj/satellite/rewards"
@ -232,7 +231,7 @@ func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
a.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: verified.Email}},
&consoleql.AccountAlreadyExistsEmail{
&console.AccountAlreadyExistsEmail{
Origin: satelliteAddress,
SatelliteName: a.SatelliteName,
SignInLink: satelliteAddress + "login",
@ -340,7 +339,7 @@ func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
a.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: user.Email, Name: userName}},
&consoleql.AccountActivationEmail{
&console.AccountActivationEmail{
ActivationLink: link,
Origin: a.ExternalAddress,
UserName: userName,
@ -527,7 +526,7 @@ func (a *Auth) ForgotPassword(w http.ResponseWriter, r *http.Request) {
a.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: email, Name: ""}},
&consoleql.UnknownResetPasswordEmail{
&console.UnknownResetPasswordEmail{
Satellite: a.SatelliteName,
Email: email,
DoubleCheckLink: doubleCheckLink,
@ -559,7 +558,7 @@ func (a *Auth) ForgotPassword(w http.ResponseWriter, r *http.Request) {
a.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: user.Email, Name: userName}},
&consoleql.ForgotPasswordEmail{
&console.ForgotPasswordEmail{
Origin: a.ExternalAddress,
UserName: userName,
ResetLink: passwordRecoveryLink,
@ -604,7 +603,7 @@ func (a *Auth) ResendEmail(w http.ResponseWriter, r *http.Request) {
a.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: verified.Email, Name: userName}},
&consoleql.ForgotPasswordEmail{
&console.ForgotPasswordEmail{
Origin: a.ExternalAddress,
UserName: userName,
ResetLink: a.PasswordRecoveryURL + "?token=" + recoveryToken,
@ -637,7 +636,7 @@ func (a *Auth) ResendEmail(w http.ResponseWriter, r *http.Request) {
a.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: user.Email, Name: userName}},
&consoleql.AccountActivationEmail{
&console.AccountActivationEmail{
Origin: a.ExternalAddress,
ActivationLink: link,
TermsAndConditionsURL: termsAndConditionsURL,

View File

@ -0,0 +1,21 @@
// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleql
const (
// ActivationPath is key for path which handles account activation.
ActivationPath = "activationPath"
// PasswordRecoveryPath is key for path which handles password recovery.
PasswordRecoveryPath = "passwordRecoveryPath"
// CancelPasswordRecoveryPath is key for path which handles let us know sequence.
CancelPasswordRecoveryPath = "cancelPasswordRecoveryPath"
// SignInPath is key for sign in server route.
SignInPath = "signInPath"
// LetUsKnowURL is key to store let us know URL.
LetUsKnowURL = "letUsKnowURL"
// ContactInfoURL is a key to store contact info URL.
ContactInfoURL = "contactInfoURL"
// TermsAndConditionsURL is a key to store terms and conditions URL.
TermsAndConditionsURL = "termsAndConditionsURL"
)

View File

@ -179,7 +179,7 @@ func rootMutation(log *zap.Logger, service *console.Service, mailService *mailse
mailService.SendRenderedAsync(
p.Context,
[]post.Address{{Address: user.Email, Name: userName}},
&ProjectInvitationEmail{
&console.ProjectInvitationEmail{
Origin: origin,
UserName: userName,
ProjectName: project.Name,

View File

@ -112,6 +112,8 @@ func TestGraphqlMutation(t *testing.T) {
consoleauth.NewService(consoleauth.Config{
TokenExpirationTime: 24 * time.Hour,
}, &consoleauth.Hmac{Secret: []byte("my-suppa-secret-key")}),
nil,
"",
console.Config{
PasswordCost: console.TestPasswordCost,
DefaultProjectLimit: 5,

View File

@ -96,6 +96,8 @@ func TestGraphqlQuery(t *testing.T) {
consoleauth.NewService(consoleauth.Config{
TokenExpirationTime: 24 * time.Hour,
}, &consoleauth.Hmac{Secret: []byte("my-suppa-secret-key")}),
nil,
"",
console.Config{
PasswordCost: console.TestPasswordCost,
DefaultProjectLimit: 5,

View File

@ -16,7 +16,6 @@ import (
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/console/consoleauth"
"storj.io/storj/satellite/console/consoleweb/consoleapi"
"storj.io/storj/satellite/console/consoleweb/consoleql"
"storj.io/storj/satellite/mailservice"
)
@ -101,7 +100,7 @@ func (chore *Chore) Run(ctx context.Context) (err error) {
err = chore.mailService.SendRendered(
ctx,
[]post.Address{{Address: u.Email, Name: userName}},
&consoleql.AccountActivationEmail{
&console.AccountActivationEmail{
ActivationLink: link,
Origin: authController.ExternalAddress,
UserName: userName,
@ -115,7 +114,7 @@ func (chore *Chore) Run(ctx context.Context) (err error) {
chore.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: u.Email, Name: userName}},
&consoleql.AccountActivationEmail{
&console.AccountActivationEmail{
ActivationLink: link,
Origin: authController.ExternalAddress,
UserName: userName,

View File

@ -1,24 +1,9 @@
// Copyright (C) 2019 Storj Labs, Inc.
// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information
package consoleql
package console
const (
// ActivationPath is key for path which handles account activation.
ActivationPath = "activationPath"
// PasswordRecoveryPath is key for path which handles password recovery.
PasswordRecoveryPath = "passwordRecoveryPath"
// CancelPasswordRecoveryPath is key for path which handles let us know sequence.
CancelPasswordRecoveryPath = "cancelPasswordRecoveryPath"
// SignInPath is key for sign in server route.
SignInPath = "signInPath"
// LetUsKnowURL is key to store let us know URL.
LetUsKnowURL = "letUsKnowURL"
// ContactInfoURL is a key to store contact info URL.
ContactInfoURL = "contactInfoURL"
// TermsAndConditionsURL is a key to store terms and conditions URL.
TermsAndConditionsURL = "termsAndConditionsURL"
)
import "time"
// AccountActivationEmail is mailservice template with activation data.
type AccountActivationEmail struct {
@ -105,3 +90,16 @@ func (*AccountAlreadyExistsEmail) Template() string { return "AccountAlreadyExis
func (*AccountAlreadyExistsEmail) Subject() string {
return "Are you trying to sign in?"
}
// LockAccountEmail is mailservice template with lock account data.
type LockAccountEmail struct {
Name string
LockoutDuration time.Duration
ResetPasswordLink string
}
// Template returns email template name.
func (*LockAccountEmail) Template() string { return "LockAccount" }
// Subject gets email subject.
func (*LockAccountEmail) Subject() string { return "Account Lock" }

View File

@ -11,6 +11,7 @@ import (
"net/http"
"net/mail"
"sort"
"strings"
"time"
"github.com/spacemonkeygo/monkit/v3"
@ -27,9 +28,11 @@ import (
"storj.io/private/cfgstruct"
"storj.io/storj/private/api"
"storj.io/storj/private/blockchain"
"storj.io/storj/private/post"
"storj.io/storj/satellite/accounting"
"storj.io/storj/satellite/analytics"
"storj.io/storj/satellite/console/consoleauth"
"storj.io/storj/satellite/mailservice"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/monetary"
"storj.io/storj/satellite/rewards"
@ -132,6 +135,9 @@ type Service struct {
loginCaptchaHandler CaptchaHandler
analytics *analytics.Service
tokens *consoleauth.Service
mailService *mailservice.Service
satelliteAddress string
config Config
}
@ -186,7 +192,7 @@ type Payments struct {
}
// NewService returns new instance of Service.
func NewService(log *zap.Logger, store DB, restKeys RESTKeys, projectAccounting accounting.ProjectAccounting, projectUsage *accounting.Service, buckets Buckets, partners *rewards.PartnersService, accounts payments.Accounts, depositWallets payments.DepositWallets, analytics *analytics.Service, tokens *consoleauth.Service, config Config) (*Service, error) {
func NewService(log *zap.Logger, store DB, restKeys RESTKeys, projectAccounting accounting.ProjectAccounting, projectUsage *accounting.Service, buckets Buckets, partners *rewards.PartnersService, accounts payments.Accounts, depositWallets payments.DepositWallets, analytics *analytics.Service, tokens *consoleauth.Service, mailService *mailservice.Service, satelliteAddress string, config Config) (*Service, error) {
if store == nil {
return nil, errs.New("store can't be nil")
}
@ -229,6 +235,8 @@ func NewService(log *zap.Logger, store DB, restKeys RESTKeys, projectAccounting
loginCaptchaHandler: loginCaptchaHandler,
analytics: analytics,
tokens: tokens,
mailService: mailService,
satelliteAddress: satelliteAddress,
config: config,
}, nil
}
@ -993,9 +1001,8 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (token consoleaut
return consoleauth.Token{}, ErrLockedAccount.New(lockedAccountErrMsg)
}
lockoutExpDate := now.Add(time.Duration(math.Pow(s.config.FailedLoginPenalty, float64(user.FailedLoginCount-1))) * time.Minute)
handleLockAccount := func() error {
err = s.UpdateUsersFailedLoginState(ctx, user, &lockoutExpDate)
err = s.UpdateUsersFailedLoginState(ctx, user)
if err != nil {
return err
}
@ -1062,7 +1069,7 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (token consoleaut
return consoleauth.Token{}, err
}
} else if request.MFAPasscode != "" {
valid, err := ValidateMFAPasscode(request.MFAPasscode, user.MFASecretKey, time.Now())
valid, err := ValidateMFAPasscode(request.MFAPasscode, user.MFASecretKey, now)
if err != nil {
err = handleLockAccount()
if err != nil {
@ -1109,10 +1116,29 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (token consoleaut
}
// UpdateUsersFailedLoginState updates User's failed login state.
func (s *Service) UpdateUsersFailedLoginState(ctx context.Context, user *User, lockoutExpDate *time.Time) error {
func (s *Service) UpdateUsersFailedLoginState(ctx context.Context, user *User) error {
updateRequest := UpdateUserRequest{}
if user.FailedLoginCount >= s.config.LoginAttemptsWithoutPenalty-1 {
updateRequest.LoginLockoutExpiration = &lockoutExpDate
lockoutDuration := time.Duration(math.Pow(s.config.FailedLoginPenalty, float64(user.FailedLoginCount-1))) * time.Minute
lockoutExpTime := time.Now().Add(lockoutDuration)
lockoutExpTimePtr := &lockoutExpTime
updateRequest.LoginLockoutExpiration = &lockoutExpTimePtr
address := s.satelliteAddress
if !strings.HasSuffix(address, "/") {
address += "/"
}
s.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: user.Email, Name: user.FullName}},
&LockAccountEmail{
Name: user.FullName,
LockoutDuration: lockoutDuration,
ResetPasswordLink: address + "forgot-password",
},
)
}
user.FailedLoginCount++

View File

@ -7,7 +7,6 @@ import (
"context"
"database/sql"
"encoding/json"
"math"
"math/big"
"testing"
"time"
@ -838,9 +837,7 @@ func TestLockAccount(t *testing.T) {
require.True(t, lockedUser.LoginLockoutExpiration.After(now))
// lock account once again and check if lockout expiration time increased.
expDuration := time.Duration(math.Pow(consoleConfig.FailedLoginPenalty, float64(lockedUser.FailedLoginCount-1))) * time.Minute
lockoutExpDate := now.Add(expDuration)
err = service.UpdateUsersFailedLoginState(userCtx, lockedUser, &lockoutExpDate)
err = service.UpdateUsersFailedLoginState(userCtx, lockedUser)
require.NoError(t, err)
lockedUser, err = service.GetUser(userCtx, user.ID)

View File

@ -90,6 +90,8 @@ func TestSignupCouponCodes(t *testing.T) {
consoleauth.NewService(consoleauth.Config{
TokenExpirationTime: 24 * time.Hour,
}, &consoleauth.Hmac{Secret: []byte("my-suppa-secret-key")}),
nil,
"",
console.Config{PasswordCost: console.TestPasswordCost, DefaultProjectLimit: 5},
)

View File

@ -0,0 +1,282 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings></xml>
<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width">
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<title></title>
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet" type="text/css">
<!--<![endif]-->
<link href="https://fonts.googleapis.com/css?family=Poppins:400,700&display=swap" rel="stylesheet">
<style type="text/css">
body {
margin: 0;
padding: 0;
}
table,
td,
tr {
vertical-align: top;
border-collapse: collapse;
}
* {
line-height: inherit;
}
a[x-apple-data-detectors=true] {
color: inherit !important;
text-decoration: none !important;
}
.im {
color: #56606D;
}
</style>
<style type="text/css" id="media-query">
@media (max-width: 540px) {
.block-grid,
.col {
min-width: 320px !important;
max-width: 100% !important;
display: block !important;
}
.block-grid {
width: 100% !important;
}
.col {
width: 100% !important;
}
.col>div {
margin: 0 auto;
}
.no-stack .col {
min-width: 0 !important;
display: table-cell !important;
}
.no-stack.two-up .col {
width: 50% !important;
}
.no-stack .col.num4 {
width: 33% !important;
}
.no-stack .col.num8 {
width: 66% !important;
}
.no-stack .col.num4 {
width: 33% !important;
}
.no-stack .col.num3 {
width: 25% !important;
}
.no-stack .col.num6 {
width: 50% !important;
}
.no-stack .col.num9 {
width: 75% !important;
}
}
</style>
<style>
@import url('https://fonts.googleapis.com/css?family=Poppins:400,500,700,900|Roboto:100,300,500,700&display=swap');
</style>
</head>
<body class="clean-body" style="margin: 0; padding: 0; -webkit-text-size-adjust: 100%; background-color: #FFFFFF;">
<!--[if IE]><div class="ie-browser"><![endif]-->
<table class="nl-container"
style="table-layout: fixed; vertical-align: top; min-width: 320px; Margin: 0 auto; border-spacing: 0;
border-collapse: collapse; mso-table-lspace: 0; mso-table-rspace: 0; background-color: #FFFFFF; width: 100%;"
cellpadding="0" cellspacing="0" role="presentation" width="100%" bgcolor="#FFFFFF" valign="top">
<tbody>
<tr style="vertical-align: top;" valign="top">
<td style="word-break: break-word; vertical-align: top;" valign="top">
<!--[if (mso)|(IE)]>
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td align="center" style="background-color:#FFFFFF">
<![endif]-->
<div style="background-color:#FFFFFF;">
<div class="block-grid "
style="Margin: 0 auto; min-width: 320px; max-width: 520px; overflow-wrap: break-word;
word-wrap: break-word; word-break: break-word; background-color: #FFFFFF;">
<div style="border-collapse: collapse;display: table;width: 100%;background-color:#FFFFFF;">
<!--[if (mso)|(IE)]>
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#FFFFFF;">
<tr><td align="center">
<table cellpadding="0" cellspacing="0" border="0" style="width:520px">
<tr class="layout-full-width" style="background-color:#FFFFFF">
<![endif]-->
<!--[if (mso)|(IE)]>
<td align="center" width="520" style="background-color:#FFFFFF;width:520px;
border-top: 0px solid #000000; border-left: 0px solid #000000;
border-bottom: 0px solid #000000; border-right: 0px solid #000000;" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td style="padding:10px 15px 0 15px;background-color:#FFFFFF;">
<![endif]-->
<div class="col num12"
style="min-width: 320px; max-width: 520px; display: table-cell; vertical-align: top; width: 520px;">
<div style="background-color:#FFFFFF;width:100% !important;">
<!--[if (!mso)&(!IE)]><!-->
<div style="border-top:0px solid #000000; border-left:0px solid #000000;
border-bottom:0px solid #000000; border-right:0px solid #000000; padding: 10px">
<!--<![endif]-->
<div>
<h1 style="font-family: sans-serif; text-align: left;
color: #000; font-weight: bold; font-size: 36px; line-height: 47px;">
Your account was locked...
</h1>
</div>
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td style="padding: 10px 10px 0 10px;font-family: Tahoma, Verdana, sans-serif">
<![endif]-->
<div style="color:#000000;font-family:sans-serif;
line-height:1.2;">
<div style="font-family: sans-serif; line-height: 1.2; font-size: 12px; color: #000000; mso-line-height-alt: 14px;">
<p style="color: #56606D; font-size: 16px; line-height: 24px; margin: 0 0 15px 0;">
Hi {{ .Name }},<br/>
Your account was locked due to too many failed login attempts. <br/><br/>
If this was you, try again in {{ .LockoutDuration }}, or
</p>
<br/>
<a
href="{{ .ResetPasswordLink }}"
target="_blank"
rel="noopener noreferrer"
style="border-radius: 4px; display: inline-block; font-size: 14px; font-weight: bold;
line-height: 24px;padding: 12px 24px; text-align: center;
text-decoration: none !important; transition: opacity 0.1s ease-in;
color: #ffffff !important; background-color: #2683ff;
font-family: Montserrat, DejaVu Sans, Verdana, sans-serif;"
>
Reset Password
</a>
<br/>
<p style="color: #56606D; font-size: 16px; line-height: 24px; margin: 25px 0 0 0;">
If this login activity doesn't look familiar, please consider updating
your password and enabling multi-factor authentication for your account
if you haven't already.
</p>
</div>
</div>
<!--[if mso]></td></tr></table><![endif]-->
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<div style="background-color:transparent;">
<div class="block-grid " style="Margin: 0 auto; min-width: 320px; max-width: 520px; overflow-wrap: break-word;
word-wrap: break-word; word-break: break-word; background-color: transparent;">
<div style="border-collapse: collapse;display: table;width: 100%;background-color:transparent;">
<!--[if (mso)|(IE)]>
<table width="100%" cellpadding="0" cellspacing="0" border="0"
style="background-color:transparent;">
<tr><td align="center">
<table cellpadding="0" cellspacing="0" border="0" style="width:520px">
<tr class="layout-full-width" style="background-color:transparent">
<![endif]-->
<!--[if (mso)|(IE)]>
<td align="center"
style="background-color:transparent;width:520px; border-top: 0px solid transparent;
border-left: 0px solid transparent; border-bottom: 0px solid transparent;
border-right: 0px solid transparent;" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td style="padding:20px 0 5px 0">
<![endif]-->
<div class="col num12" style="min-width: 320px; max-width: 520px; display: table-cell;
vertical-align: top; width: 520px;padding: 10px">
<div style="width:100% !important;">
<!--[if (!mso)&(!IE)]><!-->
<div style="border-top:0px solid transparent; border-left:0px solid transparent;
border-bottom:0px solid transparent; border-right:0px solid transparent;
padding:0 0 5px 0">
<!--<![endif]-->
<table class="divider" border="0" cellpadding="0" cellspacing="0" width="100%"
style="table-layout: fixed; vertical-align: top; border-spacing: 0;
border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt;
min-width: 100%; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;"
role="presentation" valign="top">
<tbody>
<tr style="vertical-align: top;" valign="top">
<td class="divider_inner" style="word-break: break-word; vertical-align: top;
min-width: 100%; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
padding: 10px 0 40px 0;" valign="top">
<table class="divider_content" border="0" cellpadding="0" cellspacing="0"
width="100%" style="table-layout: fixed; vertical-align: top;
border-spacing: 0; border-collapse: collapse; mso-table-lspace: 0pt;
mso-table-rspace: 0pt; border-top: 1px solid #BBBBBB; height: 0px;
width: 100%;" align="center" role="presentation" height="0"
valign="top">
<tbody>
<tr style="vertical-align: top;" valign="top">
<td style="word-break: break-word; vertical-align: top;
-ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;"
height="0" valign="top">
<span></span>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p class="size-12" style="margin: 0; color: #56606D;
font-family: sans-serif;font-size: 12px;
line-height: 19px;" lang="x-size-12">
<span>Please do not reply to this email.<br />
1450 W. Peachtree St. NW #200, PMB 75268, Atlanta, GA 30309-2955, United States
</span>
</p>
<!--[if mso]>
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td style="padding:10px; font-family: Arial, sans-serif">
<![endif]-->
<!--[if mso]></td></tr></table><![endif]-->
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if (IE)]></div><![endif]-->
</body>
</html>