mirror of
https://github.com/teamhanko/hanko.git
synced 2025-10-27 14:17:56 +08:00
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.
277 lines
8.5 KiB
Go
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
|
|
}
|