diff --git a/api/api.go b/api/api.go index 8a9fda5..d8dca2b 100644 --- a/api/api.go +++ b/api/api.go @@ -27,13 +27,13 @@ func NewAPI() (*chi.Mux, error) { return nil, err } - emailService, err := email.NewEmailService() + mailer, err := email.NewMailer() if err != nil { return nil, err } authStore := database.NewAuthStore(db) - authResource, err := auth.NewResource(authStore, emailService) + authResource, err := auth.NewResource(authStore, mailer) if err != nil { return nil, err } diff --git a/api/server.go b/api/server.go index 4bf2f60..51af668 100644 --- a/api/server.go +++ b/api/server.go @@ -46,7 +46,7 @@ func NewServer() (*Server, error) { func (srv *Server) Start() { log.Println("starting server...") go func() { - if err := srv.ListenAndServe(); err != nil { + if err := srv.ListenAndServe(); err != http.ErrServerClosed { panic(err) } }() diff --git a/auth/api.go b/auth/api.go index 719a844..3d74f56 100644 --- a/auth/api.go +++ b/auth/api.go @@ -12,8 +12,8 @@ import ( "github.com/sirupsen/logrus" ) -// Store defines database operations on account and token data. -type Store interface { +// Storer defines database operations on account and token data. +type Storer interface { GetByID(id int) (*models.Account, error) GetByEmail(email string) (*models.Account, error) GetByRefreshToken(token string) (*models.Account, *models.Token, error) @@ -23,21 +23,21 @@ type Store interface { PurgeExpiredToken() error } -// EmailService defines methods to send account emails. -type EmailService interface { - LoginToken(name, email string, c email.LoginTokenContent) error +// Mailer defines methods to send account emails. +type Mailer interface { + LoginToken(name, email string, c email.ContentLoginToken) error } // Resource implements passwordless token authentication against a database. type Resource struct { Login *LoginTokenAuth Token *TokenAuth - store Store - mailer EmailService + store Storer + mailer Mailer } // NewResource returns a configured authentication resource. -func NewResource(store Store, mailer EmailService) (*Resource, error) { +func NewResource(store Storer, mailer Mailer) (*Resource, error) { loginAuth, err := NewLoginTokenAuth() if err != nil { return nil, err @@ -55,7 +55,7 @@ func NewResource(store Store, mailer EmailService) (*Resource, error) { mailer: mailer, } - resource.Cleanup() + resource.cleanupTicker() return resource, nil } @@ -75,7 +75,7 @@ func (rs *Resource) Router() *chi.Mux { return r } -func (rs *Resource) Cleanup() { +func (rs *Resource) cleanupTicker() { ticker := time.NewTicker(time.Hour * 1) go func() { for range ticker.C { diff --git a/auth/crypto.go b/auth/crypto.go index a5c1467..d16b448 100644 --- a/auth/crypto.go +++ b/auth/crypto.go @@ -8,8 +8,7 @@ const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456 func randStringBytes(n int) string { buf := make([]byte, n) - _, err := rand.Read(buf) - if err != nil { + if _, err := rand.Read(buf); err != nil { panic(err) } diff --git a/auth/handler.go b/auth/handler.go index cafc68d..cd01415 100644 --- a/auth/handler.go +++ b/auth/handler.go @@ -65,15 +65,15 @@ func (rs *Resource) login(w http.ResponseWriter, r *http.Request) { lt := rs.Login.CreateToken(acc.ID) go func() { - err := rs.mailer.LoginToken(acc.Name, acc.Email, email.LoginTokenContent{ + content := email.ContentLoginToken{ Email: acc.Email, Name: acc.Name, URL: path.Join(rs.Login.loginURL, lt.Token), Token: lt.Token, Expiry: lt.Expiry, - }) - if err != nil { - log(r).WithField("module", "email").Error(err.Error()) + } + if err := rs.mailer.LoginToken(acc.Name, acc.Email, content); err != nil { + log(r).WithField("module", "email").Error(err) } }() @@ -128,6 +128,7 @@ func (rs *Resource) token(w http.ResponseWriter, r *http.Request) { ua := user_agent.New(r.UserAgent()) browser, _ := ua.Browser() + token := &models.Token{ Token: uuid.NewV4().String(), Expiry: time.Now().Add(time.Minute * rs.Token.jwtRefreshExpiry), diff --git a/auth/handler_test.go b/auth/handler_test.go index 51f78d5..a3c48d0 100644 --- a/auth/handler_test.go +++ b/auth/handler_test.go @@ -25,7 +25,7 @@ import ( var ( auth *Resource authstore mock.AuthStore - mailer mock.EmailService + mailer mock.Mailer ts *httptest.Server ) @@ -72,7 +72,7 @@ func TestAuthResource_login(t *testing.T) { return &a, err } - mailer.LoginTokenFn = func(n, e string, c email.LoginTokenContent) error { + mailer.LoginTokenFn = func(n, e string, c email.ContentLoginToken) error { return nil } diff --git a/auth/logintoken.go b/auth/logintoken.go index b3f3aa2..89a3e63 100644 --- a/auth/logintoken.go +++ b/auth/logintoken.go @@ -12,8 +12,8 @@ var ( errTokenNotFound = errors.New("login token not found") ) -// LoginToken is an in-memory saved token referencing an account ID and an expiry date. -type LoginToken struct { +// loginToken is an in-memory saved token referencing an account ID and an expiry date. +type loginToken struct { Token string AccountID int Expiry time.Time @@ -21,7 +21,7 @@ type LoginToken struct { // LoginTokenAuth implements passwordless login authentication flow using temporary in-memory stored tokens. type LoginTokenAuth struct { - token map[string]LoginToken + token map[string]loginToken mux sync.RWMutex loginURL string loginTokenLength int @@ -31,7 +31,7 @@ type LoginTokenAuth struct { // NewLoginTokenAuth configures and returns a LoginToken authentication instance. func NewLoginTokenAuth() (*LoginTokenAuth, error) { a := &LoginTokenAuth{ - token: make(map[string]LoginToken), + token: make(map[string]loginToken), loginURL: viper.GetString("auth_login_url"), loginTokenLength: viper.GetInt("auth_login_token_length"), loginTokenExpiry: viper.GetDuration("auth_login_token_expiry"), @@ -40,8 +40,8 @@ func NewLoginTokenAuth() (*LoginTokenAuth, error) { } // CreateToken creates an in-memory login token referencing account ID. It returns a token containing a random tokenstring and expiry date. -func (a *LoginTokenAuth) CreateToken(id int) LoginToken { - lt := LoginToken{ +func (a *LoginTokenAuth) CreateToken(id int) loginToken { + lt := loginToken{ Token: randStringBytes(a.loginTokenLength), AccountID: id, Expiry: time.Now().Add(time.Minute * a.loginTokenExpiry), @@ -61,14 +61,14 @@ func (a *LoginTokenAuth) GetAccountID(token string) (int, error) { return lt.AccountID, nil } -func (a *LoginTokenAuth) get(token string) (LoginToken, bool) { +func (a *LoginTokenAuth) get(token string) (loginToken, bool) { a.mux.RLock() lt, ok := a.token[token] a.mux.RUnlock() return lt, ok } -func (a *LoginTokenAuth) add(lt LoginToken) { +func (a *LoginTokenAuth) add(lt loginToken) { a.mux.Lock() a.token[lt.Token] = lt a.mux.Unlock() diff --git a/email/auth.go b/email/auth.go index 8bd950e..b59d590 100644 --- a/email/auth.go +++ b/email/auth.go @@ -2,7 +2,8 @@ package email import "time" -type LoginTokenContent struct { +// ContentLoginToken defines content for login token email template +type ContentLoginToken struct { Email string Name string URL string @@ -10,15 +11,16 @@ type LoginTokenContent struct { Expiry time.Time } -func (s *EmailService) LoginToken(name, address string, content LoginTokenContent) error { - msg := &Message{ - from: NewEmail(s.fromName, s.from), +// LoginToken creates and sends a login token email with provided template content +func (m *Mailer) LoginToken(name, address string, content ContentLoginToken) error { + msg := &Mail{ + from: NewEmail(m.fromName, m.from), to: NewEmail(name, address), subject: "Login Token", template: "loginToken", content: content, } - err := s.send(msg) + err := m.Send(msg) return err } diff --git a/email/email.go b/email/email.go index a02ba5e..a89708a 100644 --- a/email/email.go +++ b/email/email.go @@ -21,13 +21,15 @@ var ( debug bool ) -type EmailService struct { +// Mailer is a SMTP mailer +type Mailer struct { client *gomail.Dialer templates *template.Template from, fromName string } -func NewEmailService() (*EmailService, error) { +// NewMailer returns a configured SMTP Mailer +func NewMailer() (*Mailer, error) { templates, err := parseTemplates() if err != nil { return nil, err @@ -38,7 +40,7 @@ func NewEmailService() (*EmailService, error) { smtpUser := viper.GetString("email_smtp_user") smtpPass := viper.GetString("email_smtp_password") - s := &EmailService{ + s := &Mailer{ client: gomail.NewPlainDialer(smtpHost, smtpPort, smtpUser, smtpPass), templates: templates, from: viper.GetString("email_from_address"), @@ -56,9 +58,10 @@ func NewEmailService() (*EmailService, error) { return s, nil } -func (s *EmailService) send(msg *Message) error { +// Send parses the corrsponding template and send the mail via smtp +func (m *Mailer) Send(mail *Mail) error { buf := new(bytes.Buffer) - if err := s.templates.ExecuteTemplate(buf, msg.template, msg.content); err != nil { + if err := m.templates.ExecuteTemplate(buf, mail.template, mail.content); err != nil { return err } prem := premailer.NewPremailerFromString(buf.String(), premailer.NewOptions()) @@ -73,38 +76,27 @@ func (s *EmailService) send(msg *Message) error { } if debug { - log.Println("To:", msg.to.Address) - log.Println("Subject:", msg.subject) + log.Println("To:", mail.to.Address) + log.Println("Subject:", mail.subject) log.Println(text) return nil } - m := gomail.NewMessage() - m.SetAddressHeader("From", msg.from.Address, msg.from.Name) - m.SetAddressHeader("To", msg.to.Address, msg.to.Name) - m.SetHeader("Subject", msg.subject) - m.SetBody("text/plain", text) - m.AddAlternative("text/html", html) + msg := gomail.NewMessage() + msg.SetAddressHeader("From", mail.from.Address, mail.from.Name) + msg.SetAddressHeader("To", mail.to.Address, mail.to.Name) + msg.SetHeader("Subject", mail.subject) + msg.SetBody("text/plain", text) + msg.AddAlternative("text/html", html) - if err := s.client.DialAndSend(m); err != nil { + if err := m.client.DialAndSend(msg); err != nil { return err } return nil } -type Email struct { - Name string - Address string -} - -func NewEmail(name string, address string) *Email { - return &Email{ - Name: name, - Address: address, - } -} - -type Message struct { +// Mail struct holds all parts of a specific email +type Mail struct { from *Email to *Email subject string @@ -112,6 +104,20 @@ type Message struct { content interface{} } +// Email struct holds email address and recipient name +type Email struct { + Name string + Address string +} + +// NewEmail returns an email address +func NewEmail(name string, address string) *Email { + return &Email{ + Name: name, + Address: address, + } +} + func parseTemplates() (*template.Template, error) { tmpl := template.New("").Funcs(fMap) err := filepath.Walk("./templates", func(path string, info os.FileInfo, err error) error { diff --git a/testing/mock/emailService.go b/testing/mock/emailService.go deleted file mode 100644 index f0feaff..0000000 --- a/testing/mock/emailService.go +++ /dev/null @@ -1,13 +0,0 @@ -package mock - -import "github.com/dhax/go-base/email" - -type EmailService struct { - LoginTokenFn func(name, email string, c email.LoginTokenContent) error - LoginTokenInvoked bool -} - -func (s *EmailService) LoginToken(n, e string, c email.LoginTokenContent) error { - s.LoginTokenInvoked = true - return s.LoginTokenFn(n, e, c) -} diff --git a/testing/mock/mailer.go b/testing/mock/mailer.go new file mode 100644 index 0000000..9f07bc4 --- /dev/null +++ b/testing/mock/mailer.go @@ -0,0 +1,13 @@ +package mock + +import "github.com/dhax/go-base/email" + +type Mailer struct { + LoginTokenFn func(name, email string, c email.ContentLoginToken) error + LoginTokenInvoked bool +} + +func (s *Mailer) LoginToken(n, e string, c email.ContentLoginToken) error { + s.LoginTokenInvoked = true + return s.LoginTokenFn(n, e, c) +}