mirror of
https://github.com/teamhanko/hanko.git
synced 2025-10-26 21:57:14 +08:00
489 lines
16 KiB
Go
489 lines
16 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gobuffalo/pop/v6"
|
|
"github.com/gofrs/uuid"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
|
zeroLogger "github.com/rs/zerolog/log"
|
|
"github.com/sethvargo/go-limiter"
|
|
auditlog "github.com/teamhanko/hanko/backend/v2/audit_log"
|
|
"github.com/teamhanko/hanko/backend/v2/config"
|
|
"github.com/teamhanko/hanko/backend/v2/crypto"
|
|
"github.com/teamhanko/hanko/backend/v2/dto"
|
|
"github.com/teamhanko/hanko/backend/v2/dto/webhook"
|
|
"github.com/teamhanko/hanko/backend/v2/mail"
|
|
"github.com/teamhanko/hanko/backend/v2/persistence"
|
|
"github.com/teamhanko/hanko/backend/v2/persistence/models"
|
|
"github.com/teamhanko/hanko/backend/v2/rate_limiter"
|
|
"github.com/teamhanko/hanko/backend/v2/session"
|
|
"github.com/teamhanko/hanko/backend/v2/webhooks/events"
|
|
"github.com/teamhanko/hanko/backend/v2/webhooks/utils"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gopkg.in/gomail.v2"
|
|
)
|
|
|
|
type PasscodeHandler struct {
|
|
mailer mail.Mailer
|
|
renderer *mail.Renderer
|
|
passcodeGenerator crypto.PasscodeGenerator
|
|
persister persistence.Persister
|
|
emailConfig config.EmailDelivery
|
|
serviceConfig config.Service
|
|
TTL int
|
|
sessionManager session.Manager
|
|
cfg *config.Config
|
|
auditLogger auditlog.Logger
|
|
rateLimiter limiter.Store
|
|
}
|
|
|
|
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)
|
|
}
|
|
var rateLimiter limiter.Store
|
|
if cfg.RateLimiter.Enabled {
|
|
rateLimiter = rate_limiter.NewRateLimiter(cfg.RateLimiter, cfg.RateLimiter.PasscodeLimits)
|
|
}
|
|
return &PasscodeHandler{
|
|
mailer: mailer,
|
|
renderer: renderer,
|
|
passcodeGenerator: crypto.NewPasscodeGenerator(),
|
|
persister: persister,
|
|
emailConfig: cfg.EmailDelivery,
|
|
serviceConfig: cfg.Service,
|
|
TTL: cfg.Email.PasscodeTtl,
|
|
sessionManager: sessionManager,
|
|
cfg: cfg,
|
|
auditLogger: auditLogger,
|
|
rateLimiter: rateLimiter,
|
|
}, nil
|
|
}
|
|
|
|
func (h *PasscodeHandler) Init(c echo.Context) error {
|
|
var request dto.PasscodeInitRequest
|
|
if err := (&echo.DefaultBinder{}).BindBody(c, &request); err != nil {
|
|
return dto.ToHttpError(err)
|
|
}
|
|
|
|
if err := c.Validate(request); err != nil {
|
|
return dto.ToHttpError(err)
|
|
}
|
|
|
|
userId, err := uuid.FromString(request.UserId)
|
|
if err != nil {
|
|
return echo.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 echo.NewHTTPError(http.StatusBadRequest).SetInternal(errors.New("user not found"))
|
|
}
|
|
|
|
if h.rateLimiter != nil {
|
|
err := rate_limiter.Limit(h.rateLimiter, userId, c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
var emailId uuid.UUID
|
|
if request.EmailId != nil {
|
|
emailId, err = uuid.FromString(*request.EmailId)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "failed to parse emailId as uuid").SetInternal(err)
|
|
}
|
|
}
|
|
|
|
// Determine where to send the passcode
|
|
var email *models.Email
|
|
if !emailId.IsNil() {
|
|
// Send the passcode to the specified email address
|
|
email, err = h.persister.GetEmailPersister().Get(emailId)
|
|
if email == nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "the specified emailId is not available")
|
|
}
|
|
} else if e := user.Emails.GetPrimary(); e == nil {
|
|
// Workaround to support hanko element versions before v0.1.0-alpha:
|
|
// If user has no primary email, check if a cookie with an email id is present
|
|
emailIdCookie, err := c.Cookie("hanko_email_id")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get email id cookie: %w", err)
|
|
}
|
|
|
|
if emailIdCookie != nil && emailIdCookie.Value != "" {
|
|
emailId, err = uuid.FromString(emailIdCookie.Value)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "failed to parse emailId as uuid").SetInternal(err)
|
|
}
|
|
email, err = h.persister.GetEmailPersister().Get(emailId)
|
|
if email == nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "the specified emailId is not available")
|
|
}
|
|
} else {
|
|
// Can't determine email address to which the passcode should be sent to
|
|
return echo.NewHTTPError(http.StatusBadRequest, "an emailId needs to be specified")
|
|
}
|
|
} else {
|
|
// Send the passcode to the primary email address
|
|
email = e
|
|
}
|
|
|
|
sessionToken := h.GetSessionToken(c)
|
|
if sessionToken != nil && sessionToken.Subject() != user.ID.String() {
|
|
// if the user is logged in and the requested user in the request does not match the user from the session then sending and finalizing passcodes is not allowed
|
|
return echo.NewHTTPError(http.StatusForbidden).SetInternal(errors.New("session.userId does not match requested userId"))
|
|
}
|
|
|
|
if email.User != nil && email.User.ID.String() != user.ID.String() {
|
|
return echo.NewHTTPError(http.StatusForbidden).SetInternal(errors.New("email address is assigned to another user"))
|
|
}
|
|
|
|
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,
|
|
EmailID: &email.ID,
|
|
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")
|
|
|
|
subject := h.renderer.Translate(lang, "email_subject_login", data)
|
|
data["Subject"] = subject
|
|
|
|
bodyPlain, err := h.renderer.RenderPlain("login", lang, data)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to render email template: %w", err)
|
|
}
|
|
|
|
bodyHTML, err := h.renderer.RenderHTML("login", lang, data)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to render email template: %w", err)
|
|
}
|
|
|
|
webhookData := webhook.EmailSend{
|
|
Subject: subject,
|
|
BodyPlain: bodyPlain,
|
|
Body: bodyHTML,
|
|
ToEmailAddress: email.Address,
|
|
DeliveredByHanko: true,
|
|
AcceptLanguage: lang,
|
|
Language: lang,
|
|
Type: "passcode",
|
|
Data: webhook.PasscodeData{
|
|
ServiceName: h.cfg.Service.Name,
|
|
OtpCode: passcode,
|
|
TTL: h.TTL,
|
|
ValidUntil: passcodeModel.CreatedAt.Add(time.Duration(h.TTL) * time.Second).UTC().Unix(),
|
|
},
|
|
}
|
|
|
|
if h.cfg.EmailDelivery.Enabled {
|
|
message := gomail.NewMessage()
|
|
message.SetAddressHeader("To", email.Address, "")
|
|
message.SetAddressHeader("From", h.emailConfig.FromAddress, h.emailConfig.FromName)
|
|
|
|
message.SetHeader("Subject", subject)
|
|
|
|
message.SetBody("text/plain", bodyPlain)
|
|
message.AddAlternative("text/html", bodyHTML)
|
|
|
|
err = h.mailer.Send(message)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send passcode: %w", err)
|
|
}
|
|
|
|
err = utils.TriggerWebhooks(c, h.persister.GetConnection(), events.EmailSend, webhookData)
|
|
if err != nil {
|
|
zeroLogger.Warn().Err(err).Msg("failed to trigger webhook")
|
|
}
|
|
} else {
|
|
webhookData.DeliveredByHanko = false
|
|
err = utils.TriggerWebhooks(c, h.persister.GetConnection(), events.EmailSend, webhookData)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to trigger webhook: %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 echo.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)
|
|
emailPersister := h.persister.GetEmailPersisterWithConnection(tx)
|
|
primaryEmailPersister := h.persister.GetPrimaryEmailPersisterWithConnection(tx)
|
|
passcode, err := passcodePersister.Get(passcodeId)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get passcode: %w", err)
|
|
}
|
|
if passcode == nil {
|
|
err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogPasscodeLoginFinalFailed, nil, fmt.Errorf("unknown passcode"))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create audit log: %w", err)
|
|
}
|
|
businessError = echo.NewHTTPError(http.StatusUnauthorized, "passcode not found")
|
|
return nil
|
|
}
|
|
|
|
userModel, 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.CreateWithConnection(tx, c, models.AuditLogPasscodeLoginFinalFailed, userModel, fmt.Errorf("timed out passcode"))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create audit log: %w", err)
|
|
}
|
|
businessError = echo.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.CreateWithConnection(tx, c, models.AuditLogPasscodeLoginFinalFailed, userModel, fmt.Errorf("max attempts reached"))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create audit log: %w", err)
|
|
}
|
|
businessError = echo.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.CreateWithConnection(tx, c, models.AuditLogPasscodeLoginFinalFailed, userModel, fmt.Errorf("passcode invalid"))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create audit log: %w", err)
|
|
}
|
|
businessError = echo.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 passcode.Email.User != nil && passcode.Email.User.ID.String() != userModel.ID.String() {
|
|
return echo.NewHTTPError(http.StatusForbidden, "email address has been claimed by another user")
|
|
}
|
|
|
|
emailExistsForUser := false
|
|
for _, email := range userModel.Emails {
|
|
emailExistsForUser = email.ID == passcode.Email.ID
|
|
if emailExistsForUser {
|
|
break
|
|
}
|
|
}
|
|
|
|
existingSessionToken := h.GetSessionToken(c)
|
|
// return forbidden when none of these cases matches
|
|
if !((existingSessionToken == nil && emailExistsForUser) || // normal login: when user logs in and the email used is associated with the user
|
|
(existingSessionToken == nil && len(userModel.Emails) == 0) || // register: when user register and the user has no emails
|
|
(existingSessionToken != nil && existingSessionToken.Subject() == userModel.ID.String())) { // add email through profile: when the user adds an email while having a session and the userIds requested in the passcode and the one in the session matches
|
|
return echo.NewHTTPError(http.StatusForbidden).SetInternal(errors.New("passcode finalization not allowed"))
|
|
}
|
|
|
|
wasUnverified := false
|
|
hasEmails := len(userModel.Emails) >= 1 // check if we need to trigger a UserCreate webhook or a EmailCreate one
|
|
|
|
if !passcode.Email.Verified {
|
|
wasUnverified = true
|
|
|
|
// Update email verified status and assign the email address to the user.
|
|
passcode.Email.Verified = true
|
|
passcode.Email.UserID = &userModel.ID
|
|
|
|
err = emailPersister.Update(passcode.Email)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update the email verified status: %w", err)
|
|
}
|
|
|
|
if userModel.Emails.GetPrimary() == nil {
|
|
primaryEmail := models.NewPrimaryEmail(passcode.Email.ID, userModel.ID)
|
|
err = primaryEmailPersister.Create(*primaryEmail)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create primary email: %w", err)
|
|
}
|
|
|
|
userModel.Emails = models.Emails{passcode.Email}
|
|
userModel.SetPrimaryEmail(primaryEmail)
|
|
err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogPrimaryEmailChanged, userModel, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create audit log: %w", err)
|
|
}
|
|
}
|
|
|
|
err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogEmailVerified, userModel, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create audit log: %w", err)
|
|
}
|
|
}
|
|
|
|
var emailJwt *dto.EmailJWT
|
|
if e := userModel.Emails.GetPrimary(); e != nil {
|
|
emailJwt = dto.EmailJWTFromEmailModel(e)
|
|
}
|
|
|
|
token, rawToken, err := h.sessionManager.GenerateJWT(dto.UserJWT{
|
|
UserID: passcode.UserId.String(),
|
|
Email: emailJwt,
|
|
})
|
|
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)
|
|
}
|
|
|
|
err = storeSession(h.cfg, h.persister, *passcode.UserId, rawToken, c, tx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to store session in DB: %w", err)
|
|
}
|
|
|
|
c.Response().Header().Set("X-Session-Lifetime", fmt.Sprintf("%d", cookie.MaxAge))
|
|
|
|
if h.cfg.Session.EnableAuthTokenHeader {
|
|
c.Response().Header().Set("X-Auth-Token", token)
|
|
} else {
|
|
c.SetCookie(cookie)
|
|
}
|
|
|
|
err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogPasscodeLoginFinalSucceeded, userModel, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create audit log: %w", err)
|
|
}
|
|
|
|
// notify about email verification result. Last step to prevent a trigger and rollback scenario
|
|
if h.cfg.Email.RequireVerification && wasUnverified {
|
|
var evt events.Event
|
|
|
|
if hasEmails {
|
|
evt = events.UserEmailCreate
|
|
} else {
|
|
evt = events.UserCreate
|
|
}
|
|
|
|
utils.NotifyUserChange(c, tx, h.persister, evt, userModel.ID)
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, dto.PasscodeReturn{
|
|
Id: passcode.ID.String(),
|
|
TTL: passcode.Ttl,
|
|
CreatedAt: passcode.CreatedAt,
|
|
})
|
|
})
|
|
|
|
if businessError != nil {
|
|
return businessError
|
|
}
|
|
|
|
return transactionError
|
|
}
|
|
|
|
func (h *PasscodeHandler) GetSessionToken(c echo.Context) jwt.Token {
|
|
var token jwt.Token
|
|
sessionCookie, _ := c.Cookie(h.cfg.Session.Cookie.GetName())
|
|
// we don't need to check the error, because when the cookie can not be found, the user is not logged in
|
|
if sessionCookie != nil {
|
|
token, _ = h.sessionManager.Verify(sessionCookie.Value)
|
|
// we don't need to check the error, because when the token is not returned, the user is not logged in
|
|
}
|
|
|
|
if token == nil {
|
|
authorizationHeader := c.Request().Header.Get("Authorization")
|
|
sessionToken := strings.TrimPrefix(authorizationHeader, "Bearer")
|
|
if strings.TrimSpace(sessionToken) != "" {
|
|
token, _ = h.sessionManager.Verify(sessionToken)
|
|
}
|
|
}
|
|
|
|
return token
|
|
}
|