Files
hanko/backend/handler/passcode.go
lfleischmann 3aef985fd9 fix: persisted passcode timestamps (#311)
Time.Now() uses local time but timezone information is lost on persisting
because pop timestamp columns are without timezone. On retrieval from the
DB the original timestamp is not wholly recoverable and leads to erroneous
comparisons (e.g. passcode expiry check). This commit changes that by
explicitly using UTC both on save and comparison.
2022-10-18 17:36:36 +02:00

277 lines
8.5 KiB
Go

package handler
import (
"errors"
"fmt"
"github.com/gobuffalo/pop/v6"
"github.com/gofrs/uuid"
"github.com/labstack/echo/v4"
"github.com/teamhanko/hanko/backend/audit_log"
"github.com/teamhanko/hanko/backend/config"
"github.com/teamhanko/hanko/backend/crypto"
"github.com/teamhanko/hanko/backend/dto"
"github.com/teamhanko/hanko/backend/mail"
"github.com/teamhanko/hanko/backend/persistence"
"github.com/teamhanko/hanko/backend/persistence/models"
"github.com/teamhanko/hanko/backend/session"
"golang.org/x/crypto/bcrypt"
"gopkg.in/gomail.v2"
"net/http"
"time"
)
type PasscodeHandler struct {
mailer mail.Mailer
renderer *mail.Renderer
passcodeGenerator crypto.PasscodeGenerator
persister persistence.Persister
emailConfig config.Email
serviceConfig config.Service
TTL int
sessionManager session.Manager
cfg *config.Config
auditLogger auditlog.Logger
}
var maxPasscodeTries = 3
func NewPasscodeHandler(cfg *config.Config, persister persistence.Persister, sessionManager session.Manager, mailer mail.Mailer, auditLogger auditlog.Logger) (*PasscodeHandler, error) {
renderer, err := mail.NewRenderer()
if err != nil {
return nil, fmt.Errorf("failed to create new renderer: %w", err)
}
return &PasscodeHandler{
mailer: mailer,
renderer: renderer,
passcodeGenerator: crypto.NewPasscodeGenerator(),
persister: persister,
emailConfig: cfg.Passcode.Email,
serviceConfig: cfg.Service,
TTL: cfg.Passcode.TTL,
sessionManager: sessionManager,
cfg: cfg,
auditLogger: auditLogger,
}, nil
}
func (h *PasscodeHandler) Init(c echo.Context) error {
var body dto.PasscodeInitRequest
if err := (&echo.DefaultBinder{}).BindBody(c, &body); err != nil {
return dto.ToHttpError(err)
}
if err := c.Validate(body); err != nil {
return dto.ToHttpError(err)
}
userId, err := uuid.FromString(body.UserId)
if err != nil {
return dto.NewHTTPError(http.StatusBadRequest, "failed to parse userId as uuid").SetInternal(err)
}
user, err := h.persister.GetUserPersister().Get(userId)
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
if user == nil {
err = h.auditLogger.Create(c, models.AuditLogPasscodeLoginInitFailed, nil, fmt.Errorf("unknown user"))
if err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
return dto.NewHTTPError(http.StatusBadRequest).SetInternal(errors.New("user not found"))
}
passcode, err := h.passcodeGenerator.Generate()
if err != nil {
return fmt.Errorf("failed to generate passcode: %w", err)
}
passcodeId, err := uuid.NewV4()
if err != nil {
return fmt.Errorf("failed to create passcodeId: %w", err)
}
now := time.Now().UTC()
hashedPasscode, err := bcrypt.GenerateFromPassword([]byte(passcode), 12)
if err != nil {
return fmt.Errorf("failed to hash passcode: %w", err)
}
passcodeModel := models.Passcode{
ID: passcodeId,
UserId: userId,
Ttl: h.TTL,
Code: string(hashedPasscode),
CreatedAt: now,
UpdatedAt: now,
}
err = h.persister.GetPasscodePersister().Create(passcodeModel)
if err != nil {
return fmt.Errorf("failed to store passcode: %w", err)
}
durationTTL := time.Duration(h.TTL) * time.Second
data := map[string]interface{}{
"Code": passcode,
"ServiceName": h.serviceConfig.Name,
"TTL": fmt.Sprintf("%.0f", durationTTL.Minutes()),
}
lang := c.Request().Header.Get("Accept-Language")
str, err := h.renderer.Render("loginTextMail", lang, data)
if err != nil {
return fmt.Errorf("failed to render email template: %w", err)
}
message := gomail.NewMessage()
message.SetAddressHeader("To", user.Email, "")
message.SetAddressHeader("From", h.emailConfig.FromAddress, h.emailConfig.FromName)
message.SetHeader("Subject", h.renderer.Translate(lang, "email_subject_login", data))
message.SetBody("text/plain", str)
err = h.mailer.Send(message)
if err != nil {
return fmt.Errorf("failed to send passcode: %w", err)
}
err = h.auditLogger.Create(c, models.AuditLogPasscodeLoginInitSucceeded, user, nil)
if err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
return c.JSON(http.StatusOK, dto.PasscodeReturn{
Id: passcodeId.String(),
TTL: h.TTL,
CreatedAt: passcodeModel.CreatedAt,
})
}
func (h *PasscodeHandler) Finish(c echo.Context) error {
startTime := time.Now().UTC()
var body dto.PasscodeFinishRequest
if err := (&echo.DefaultBinder{}).BindBody(c, &body); err != nil {
return dto.ToHttpError(err)
}
if err := c.Validate(body); err != nil {
return dto.ToHttpError(err)
}
passcodeId, err := uuid.FromString(body.Id)
if err != nil {
return dto.NewHTTPError(http.StatusBadRequest, "failed to parse passcodeId as uuid").SetInternal(err)
}
// only if an internal server error occurs the transaction should be rolled back
var businessError error
transactionError := h.persister.Transaction(func(tx *pop.Connection) error {
passcodePersister := h.persister.GetPasscodePersisterWithConnection(tx)
userPersister := h.persister.GetUserPersisterWithConnection(tx)
passcode, err := passcodePersister.Get(passcodeId)
if err != nil {
return fmt.Errorf("failed to get passcode: %w", err)
}
if passcode == nil {
err = h.auditLogger.Create(c, models.AuditLogPasscodeLoginFinalFailed, nil, fmt.Errorf("unknown passcode"))
if err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
businessError = dto.NewHTTPError(http.StatusUnauthorized, "passcode not found")
return nil
}
user, err := userPersister.Get(passcode.UserId)
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
lastVerificationTime := passcode.CreatedAt.Add(time.Duration(passcode.Ttl) * time.Second)
if lastVerificationTime.Before(startTime) {
err = h.auditLogger.Create(c, models.AuditLogPasscodeLoginFinalFailed, user, fmt.Errorf("timed out passcode"))
if err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
businessError = dto.NewHTTPError(http.StatusRequestTimeout, "passcode request timed out").SetInternal(errors.New(fmt.Sprintf("createdAt: %s -> lastVerificationTime: %s", passcode.CreatedAt, lastVerificationTime))) // TODO: maybe we should use BadRequest, because RequestTimeout might be to technical and can refer to different error
return nil
}
err = bcrypt.CompareHashAndPassword([]byte(passcode.Code), []byte(body.Code))
if err != nil {
passcode.TryCount = passcode.TryCount + 1
if passcode.TryCount >= maxPasscodeTries {
err = passcodePersister.Delete(*passcode)
if err != nil {
return fmt.Errorf("failed to delete passcode: %w", err)
}
err = h.auditLogger.Create(c, models.AuditLogPasscodeLoginFinalFailed, user, fmt.Errorf("max attempts reached"))
if err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
businessError = dto.NewHTTPError(http.StatusGone, "max attempts reached")
return nil
}
err = passcodePersister.Update(*passcode)
if err != nil {
return fmt.Errorf("failed to update passcode: %w", err)
}
err = h.auditLogger.Create(c, models.AuditLogPasscodeLoginFinalFailed, user, fmt.Errorf("passcode invalid"))
if err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
businessError = dto.NewHTTPError(http.StatusUnauthorized).SetInternal(errors.New("passcode invalid"))
return nil
}
err = passcodePersister.Delete(*passcode)
if err != nil {
return fmt.Errorf("failed to delete passcode: %w", err)
}
if !user.Verified {
user.Verified = true
err = userPersister.Update(*user)
if err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
}
token, err := h.sessionManager.GenerateJWT(passcode.UserId)
if err != nil {
return fmt.Errorf("failed to generate jwt: %w", err)
}
cookie, err := h.sessionManager.GenerateCookie(token)
if err != nil {
return fmt.Errorf("failed to create session token: %w", err)
}
c.SetCookie(cookie)
if h.cfg.Session.EnableAuthTokenHeader {
c.Response().Header().Set("X-Auth-Token", token)
c.Response().Header().Set("Access-Control-Expose-Headers", "X-Auth-Token")
}
err = h.auditLogger.Create(c, models.AuditLogPasscodeLoginFinalSucceeded, user, nil)
if err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
return c.JSON(http.StatusOK, dto.PasscodeReturn{
Id: passcode.ID.String(),
TTL: passcode.Ttl,
CreatedAt: passcode.CreatedAt,
})
})
if businessError != nil {
return businessError
}
return transactionError
}