Files
Matthew H. Irby e7a5c2df27 Feat: Add logout method to sdk (#566)
* Add endpoint to invalidate HTTP-Only cookie from the backend

* Add methods to the UserClient SDK for logout

* Remove session token fetch and add unit test for logout

* Update public router to use JWT middleware

* Add logout button to frontend. Route back to login page once logout is successful.

* Add a logout failur event

* Update logout logic in SDK

* Remove unneeded endpoint from main.go

* Update logoutlink reference

* Fix request path; undo change in package order

* Update common.css to incldue hanko-logout

* feat(fronend-sdk): remove cookie during cross-domain operations

* fix(frontend-sdk): No unauthorized error during logout, when the user is already logged out

* feat(backend): Create an audit log entry when the user logs off

* chore(frontend-sdk): re-generate jsdoc

* fix: Adjust logout response codes and the corresponding frontend sdk error handling

* chore(frontend-sdk): re-generate jsdoc

* feat: add logout endpoint specification to the docs

* Fix broken unit test

* Remove logout button from elements

* Add event listener on frontend to call the logout method from SDK

* Rollback changes to SecuredContent on e2e tests

* Update logout test on user

* Update quickstart/public/assets/css/common.css

Co-authored-by: bjoern-m <56024829+bjoern-m@users.noreply.github.com>

---------

Co-authored-by: Björn Müller <bjoern.mueller@hanko.io>
Co-authored-by: bjoern-m <56024829+bjoern-m@users.noreply.github.com>
2023-03-03 10:48:33 +01:00

250 lines
7.2 KiB
Go

package handler
import (
"errors"
"fmt"
"github.com/gobuffalo/pop/v6"
"github.com/gofrs/uuid"
"github.com/labstack/echo/v4"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/teamhanko/hanko/backend/audit_log"
"github.com/teamhanko/hanko/backend/config"
"github.com/teamhanko/hanko/backend/dto"
"github.com/teamhanko/hanko/backend/persistence"
"github.com/teamhanko/hanko/backend/persistence/models"
"github.com/teamhanko/hanko/backend/session"
"net/http"
"strings"
)
type UserHandler struct {
persister persistence.Persister
sessionManager session.Manager
auditLogger auditlog.Logger
cfg *config.Config
}
func NewUserHandler(cfg *config.Config, persister persistence.Persister, sessionManager session.Manager, auditLogger auditlog.Logger) *UserHandler {
return &UserHandler{
persister: persister,
auditLogger: auditLogger,
sessionManager: sessionManager,
cfg: cfg,
}
}
type UserCreateBody struct {
Email string `json:"email" validate:"required,email"`
}
func (h *UserHandler) Create(c echo.Context) error {
var body UserCreateBody
if err := (&echo.DefaultBinder{}).BindBody(c, &body); err != nil {
return dto.ToHttpError(err)
}
if err := c.Validate(body); err != nil {
return dto.ToHttpError(err)
}
body.Email = strings.ToLower(body.Email)
return h.persister.Transaction(func(tx *pop.Connection) error {
newUser := models.NewUser()
err := h.persister.GetUserPersisterWithConnection(tx).Create(newUser)
if err != nil {
return fmt.Errorf("failed to store user: %w", err)
}
email, err := h.persister.GetEmailPersisterWithConnection(tx).FindByAddress(body.Email)
if err != nil {
return fmt.Errorf("failed to get email: %w", err)
}
if email != nil {
if email.UserID != nil {
// The email already exists and is assigned already.
return dto.NewHTTPError(http.StatusConflict).SetInternal(errors.New(fmt.Sprintf("user with email %s already exists", body.Email)))
}
if !h.cfg.Emails.RequireVerification {
// Assign the email address to the user because it's currently unassigned and email verification is turned off.
email.UserID = &newUser.ID
err = h.persister.GetEmailPersisterWithConnection(tx).Update(*email)
if err != nil {
return fmt.Errorf("failed to update email address: %w", err)
}
}
} else {
// The email address does not exist, create a new one.
if h.cfg.Emails.RequireVerification {
// The email can only be assigned to the user via passcode verification.
email = models.NewEmail(nil, body.Email)
} else {
email = models.NewEmail(&newUser.ID, body.Email)
}
err = h.persister.GetEmailPersisterWithConnection(tx).Create(*email)
if err != nil {
return fmt.Errorf("failed to store user: %w", err)
}
}
if !h.cfg.Emails.RequireVerification {
primaryEmail := models.NewPrimaryEmail(email.ID, newUser.ID)
err = h.persister.GetPrimaryEmailPersisterWithConnection(tx).Create(*primaryEmail)
if err != nil {
return fmt.Errorf("failed to store primary email: %w", err)
}
token, err := h.sessionManager.GenerateJWT(newUser.ID)
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.AuditLogUserCreated, &newUser, nil)
if err != nil {
return fmt.Errorf("failed to write audit log: %w", err)
}
// This cookie is a workaround for hanko element versions before 0.1.0-alpha,
// because else the backend would not know where to send the first passcode.
c.SetCookie(&http.Cookie{
Name: "hanko_email_id",
Value: email.ID.String(),
Domain: h.cfg.Session.Cookie.Domain,
Secure: h.cfg.Session.Cookie.Secure,
HttpOnly: h.cfg.Session.Cookie.HttpOnly,
SameSite: http.SameSiteNoneMode,
})
return c.JSON(http.StatusOK, dto.CreateUserResponse{
ID: newUser.ID,
UserID: newUser.ID,
EmailID: email.ID,
})
})
}
func (h *UserHandler) Get(c echo.Context) error {
userId := c.Param("id")
sessionToken, ok := c.Get("session").(jwt.Token)
if !ok {
return errors.New("missing or malformed jwt")
}
if sessionToken.Subject() != userId {
return dto.NewHTTPError(http.StatusForbidden).SetInternal(errors.New(fmt.Sprintf("user %s tried to get user %s", sessionToken.Subject(), userId)))
}
user, err := h.persister.GetUserPersister().Get(uuid.FromStringOrNil(userId))
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
if user == nil {
return dto.NewHTTPError(http.StatusNotFound).SetInternal(errors.New("user not found"))
}
var emailAddress *string
if e := user.Emails.GetPrimary(); e != nil {
emailAddress = &e.Address
}
return c.JSON(http.StatusOK, dto.GetUserResponse{
ID: user.ID,
WebauthnCredentials: user.WebauthnCredentials,
Email: emailAddress,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
})
}
type UserGetByEmailBody struct {
Email string `json:"email" validate:"required,email"`
}
func (h *UserHandler) GetUserIdByEmail(c echo.Context) error {
var request UserGetByEmailBody
if err := (&echo.DefaultBinder{}).BindBody(c, &request); err != nil {
return dto.ToHttpError(err)
}
if err := c.Validate(request); err != nil {
return dto.ToHttpError(err)
}
emailAddress := strings.ToLower(request.Email)
email, err := h.persister.GetEmailPersister().FindByAddress(emailAddress)
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
if email == nil || email.UserID == nil {
return dto.NewHTTPError(http.StatusNotFound).SetInternal(errors.New("user not found"))
}
credentials, err := h.persister.GetWebauthnCredentialPersister().GetFromUser(*email.UserID)
if err != nil {
return fmt.Errorf("failed to get webauthn credentials: %w", err)
}
return c.JSON(http.StatusOK, dto.UserInfoResponse{
ID: *email.UserID,
Verified: email.Verified,
EmailID: email.ID,
HasWebauthnCredential: len(credentials) > 0,
})
}
func (h *UserHandler) Me(c echo.Context) error {
sessionToken, ok := c.Get("session").(jwt.Token)
if !ok {
return errors.New("failed to cast session object")
}
return c.JSON(http.StatusOK, map[string]string{"id": sessionToken.Subject()})
}
func (h *UserHandler) Logout(c echo.Context) error {
sessionToken, ok := c.Get("session").(jwt.Token)
if !ok {
return errors.New("missing or malformed jwt")
}
userId := uuid.FromStringOrNil(sessionToken.Subject())
user, err := h.persister.GetUserPersister().Get(userId)
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
err = h.auditLogger.Create(c, models.AuditLogUserLoggedOut, user, nil)
if err != nil {
return fmt.Errorf("failed to write audit log: %w", err)
}
cookie, err := h.sessionManager.DeleteCookie()
if err != nil {
return fmt.Errorf("failed to create session token: %w", err)
}
c.SetCookie(cookie)
return c.NoContent(http.StatusNoContent)
}