diff --git a/backend/audit_log/logger.go b/backend/audit_log/logger.go index 687b1ae0d..f4f8c593b 100644 --- a/backend/audit_log/logger.go +++ b/backend/audit_log/logger.go @@ -123,6 +123,9 @@ func (l *logger) logToConsole(auditLog models.AuditLog) { } func (l *logger) getRequestMeta(c echo.Context) models.RequestMeta { + if c == nil { + return models.RequestMeta{} + } return models.RequestMeta{ HttpRequestId: c.Response().Header().Get(echo.HeaderXRequestID), UserAgent: c.Request().UserAgent(), diff --git a/backend/dto/admin/session.go b/backend/dto/admin/session.go new file mode 100644 index 000000000..da16b0b7a --- /dev/null +++ b/backend/dto/admin/session.go @@ -0,0 +1,11 @@ +package admin + +type CreateSessionTokenDto struct { + UserID string `json:"user_id" validate:"required,uuid4"` + UserAgent string `json:"user_agent"` + IpAddress string `json:"ip_address"` +} + +type CreateSessionTokenResponse struct { + SessionToken string `json:"session_token"` +} diff --git a/backend/handler/admin_router.go b/backend/handler/admin_router.go index 2bbd2fa87..8e71aa433 100644 --- a/backend/handler/admin_router.go +++ b/backend/handler/admin_router.go @@ -5,11 +5,13 @@ import ( "github.com/labstack/echo-contrib/echoprometheus" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" + auditlog "github.com/teamhanko/hanko/backend/audit_log" "github.com/teamhanko/hanko/backend/config" "github.com/teamhanko/hanko/backend/crypto/jwk" "github.com/teamhanko/hanko/backend/dto" hankoMiddleware "github.com/teamhanko/hanko/backend/middleware" "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/session" "github.com/teamhanko/hanko/backend/template" ) @@ -48,8 +50,13 @@ func NewAdminRouter(cfg *config.Config, persister persistence.Persister, prometh if err != nil { panic(fmt.Errorf("failed to create jwk manager: %w", err)) } + sessionManager, err := session.NewManager(jwkManager, *cfg) + if err != nil { + panic(fmt.Errorf("failed to create session generator: %w", err)) + } webhookMiddleware := hankoMiddleware.WebhookMiddleware(cfg, jwkManager, persister) + auditLogger := auditlog.NewLogger(persister, cfg.AuditLog) userHandler := NewUserHandlerAdmin(persister) emailHandler := NewEmailAdminHandler(cfg, persister) @@ -80,5 +87,9 @@ func NewAdminRouter(cfg *config.Config, persister persistence.Persister, prometh webhooks.DELETE("/:id", webhookHandler.Delete) webhooks.PUT("/:id", webhookHandler.Update) + sessionsHandler := NewSessionAdminHandler(cfg, persister, sessionManager, auditLogger) + sessions := g.Group("/sessions") + sessions.POST("", sessionsHandler.Generate) + return e } diff --git a/backend/handler/session_admin.go b/backend/handler/session_admin.go new file mode 100644 index 000000000..215902510 --- /dev/null +++ b/backend/handler/session_admin.go @@ -0,0 +1,113 @@ +package handler + +import ( + "fmt" + "github.com/gofrs/uuid" + "github.com/labstack/echo/v4" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/config" + "github.com/teamhanko/hanko/backend/dto" + "github.com/teamhanko/hanko/backend/dto/admin" + "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/persistence/models" + "github.com/teamhanko/hanko/backend/session" + "net/http" +) + +type SessionAdminHandler struct { + cfg *config.Config + persister persistence.Persister + sessionManger session.Manager + auditLogger auditlog.Logger +} + +func NewSessionAdminHandler(cfg *config.Config, persister persistence.Persister, sessionManager session.Manager, auditLogger auditlog.Logger) SessionAdminHandler { + return SessionAdminHandler{ + cfg: cfg, + persister: persister, + sessionManger: sessionManager, + auditLogger: auditLogger, + } +} + +func (h *SessionAdminHandler) Generate(ctx echo.Context) error { + var body admin.CreateSessionTokenDto + if err := (&echo.DefaultBinder{}).BindBody(ctx, &body); err != nil { + return dto.ToHttpError(err) + } + + if err := ctx.Validate(body); err != nil { + return dto.ToHttpError(err) + } + + userID, err := uuid.FromString(body.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 err + } + + if user == nil { + return echo.NewHTTPError(http.StatusNotFound, "user not found") + } + + var emailDTO *dto.EmailJwt + if email := user.Emails.GetPrimary(); email != nil { + emailDTO = dto.JwtFromEmailModel(email) + } + + encodedToken, rawToken, err := h.sessionManger.GenerateJWT(userID, emailDTO) + if err != nil { + return fmt.Errorf("failed to generate JWT: %w", err) + } + + activeSessions, err := h.persister.GetSessionPersister().ListActive(userID) + if err != nil { + return fmt.Errorf("failed to list active sessions: %w", err) + } + + if h.cfg.Session.ServerSide.Enabled { + // remove all server side sessions that exceed the limit + if len(activeSessions) >= h.cfg.Session.ServerSide.Limit { + for i := h.cfg.Session.ServerSide.Limit - 1; i < len(activeSessions); i++ { + err = h.persister.GetSessionPersister().Delete(activeSessions[i]) + if err != nil { + return fmt.Errorf("failed to remove latest session: %w", err) + } + } + } + + sessionID, _ := rawToken.Get("session_id") + + expirationTime := rawToken.Expiration() + sessionModel := models.Session{ + ID: uuid.FromStringOrNil(sessionID.(string)), + UserID: userID, + UserAgent: body.UserAgent, + IpAddress: body.IpAddress, + CreatedAt: rawToken.IssuedAt(), + UpdatedAt: rawToken.IssuedAt(), + ExpiresAt: &expirationTime, + LastUsed: rawToken.IssuedAt(), + } + + err = h.persister.GetSessionPersister().Create(sessionModel) + if err != nil { + return fmt.Errorf("failed to store session: %w", err) + } + } + + response := admin.CreateSessionTokenResponse{ + SessionToken: encodedToken, + } + + err = h.auditLogger.Create(nil, models.AuditLogLoginSuccess, user, nil, auditlog.Detail("api", "admin")) + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + + return ctx.JSON(http.StatusOK, response) +} diff --git a/backend/persistence/migrations/20241106171500_change_sessions.down.fizz b/backend/persistence/migrations/20241106171500_change_sessions.down.fizz new file mode 100644 index 000000000..5fe323206 --- /dev/null +++ b/backend/persistence/migrations/20241106171500_change_sessions.down.fizz @@ -0,0 +1,2 @@ +change_column("sessions", "user_agent", "string", {"null": false}) +change_column("sessions", "ip_address", "string", {"null": false}) diff --git a/backend/persistence/migrations/20241106171500_change_sessions.up.fizz b/backend/persistence/migrations/20241106171500_change_sessions.up.fizz new file mode 100644 index 000000000..7ccf7ea2e --- /dev/null +++ b/backend/persistence/migrations/20241106171500_change_sessions.up.fizz @@ -0,0 +1,2 @@ +change_column("sessions", "user_agent", "string", {"null": true}) +change_column("sessions", "ip_address", "string", {"null": true})