From bf925d8d70190e458e4ef1c311cc025b27a195ce Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 22 Mar 2026 20:53:35 +0100 Subject: [PATCH] Add `GET /sessions` --- auth/go.mod | 5 +- auth/go.sum | 2 + auth/main.go | 2 + auth/sessions.go | 106 ++++++++++++++++++++++++++++++++++++--- auth/tests/sessions.hurl | 61 ++++++++++++++++++++++ auth/utils.go | 8 +-- 6 files changed, 169 insertions(+), 15 deletions(-) create mode 100644 auth/tests/sessions.hurl diff --git a/auth/go.mod b/auth/go.mod index da691577..5e600665 100644 --- a/auth/go.mod +++ b/auth/go.mod @@ -1,8 +1,6 @@ module github.com/zoriya/kyoo/keibi -go 1.25.0 - -toolchain go1.26.0 +go 1.26.0 require ( github.com/alexedwards/argon2id v1.0.0 @@ -81,6 +79,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mileusna/useragent v1.3.5 github.com/swaggo/files/v2 v2.0.2 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 diff --git a/auth/go.sum b/auth/go.sum index 1a440a88..063ed7f4 100644 --- a/auth/go.sum +++ b/auth/go.sum @@ -130,6 +130,8 @@ github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLO github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws= +github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= diff --git a/auth/main.go b/auth/main.go index ff89bae2..2948ff3c 100644 --- a/auth/main.go +++ b/auth/main.go @@ -346,8 +346,10 @@ func main() { g.POST("/users", h.Register) g.POST("/sessions", h.Login) + r.GET("/sessions", h.ListMySessions) r.DELETE("/sessions", h.Logout) r.DELETE("/sessions/:id", h.Logout) + r.GET("/users/:id/sessions", h.ListUserSessions) r.GET("/keys", h.ListApiKey) r.POST("/keys", h.CreateApiKey) diff --git a/auth/sessions.go b/auth/sessions.go index 7c174b8d..9476be77 100644 --- a/auth/sessions.go +++ b/auth/sessions.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/base64" "net/http" + "strings" "time" "github.com/alexedwards/argon2id" @@ -12,6 +13,7 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/labstack/echo/v5" + "github.com/mileusna/useragent" "github.com/zoriya/kyoo/keibi/dbc" ) @@ -32,23 +34,30 @@ type SessionWToken struct { } func MapSession(ses *dbc.Session) Session { + dev := ses.Device + if ses.Device != nil { + ua := useragent.Parse(*ses.Device) + uae := ([]string{ua.Name}) + if ua.Device != "" { + uae = append(uae, ua.Device) + } + if ua.OS != "" { + uae = append(uae, ua.OS) + } + dev = new(strings.Join(uae, " - ")) + } return Session{ Id: ses.Id, CreatedDate: ses.CreatedDate, LastUsed: ses.LastUsed, - Device: ses.Device, + Device: dev, } } func MapSessionToken(ses *dbc.Session) SessionWToken { return SessionWToken{ - Session: Session{ - Id: ses.Id, - CreatedDate: ses.CreatedDate, - LastUsed: ses.LastUsed, - Device: ses.Device, - }, - Token: ses.Token, + Session: MapSession(ses), + Token: ses.Token, } } @@ -128,6 +137,87 @@ func (h *Handler) createSession(c *echo.Context, user *User) error { return c.JSON(201, MapSessionToken(&session)) } +// @Summary List my sessions +// @Description List all active sessions for the currently connected user +// @Tags sessions +// @Produce json +// @Security Jwt +// @Success 200 {array} Session +// @Failure 401 {object} KError "Missing jwt token" +// @Failure 403 {object} KError "Invalid jwt token (or expired)" +// @Router /sessions [get] +func (h *Handler) ListMySessions(c *echo.Context) error { + ctx := c.Request().Context() + uid, err := GetCurrentUserId(c) + if err != nil { + return err + } + + users, err := h.db.GetUser(ctx, dbc.GetUserParams{ + UseId: true, + Id: uid, + }) + if err != nil { + return err + } + + dbSessions, err := h.db.GetUserSessions(ctx, users[0].User.Pk) + if err != nil { + return err + } + + ret := make([]Session, 0, len(dbSessions)) + for _, ses := range dbSessions { + ret = append(ret, MapSession(&ses)) + } + + return c.JSON(http.StatusOK, ret) +} + +// @Summary List user sessions +// @Description List all active sessions for a user. Listing someone else's sessions requires users.read. +// @Tags sessions +// @Produce json +// @Security Jwt +// @Param id path string true "The id or username of the user" Example(e05089d6-9179-4b5b-a63e-94dd5fc2a397) +// @Success 200 {array} Session +// @Failure 401 {object} KError "Missing jwt token" +// @Failure 403 {object} KError "Missing permissions: users.read." +// @Failure 404 {object} KError "No user found with id or username" +// @Router /users/{id}/sessions [get] +func (h *Handler) ListUserSessions(c *echo.Context) error { + ctx := c.Request().Context() + if err := CheckPermissions(c, []string{"users.read"}); err != nil { + return err + } + + id := c.Param("id") + uid, err := uuid.Parse(id) + users, err := h.db.GetUser(ctx, dbc.GetUserParams{ + UseId: err == nil, + Id: uid, + Username: id, + }) + if err != nil { + return err + } + if len(users) == 0 { + return echo.NewHTTPError(http.StatusNotFound, "No user found with id or username") + } + + dbSessions, err := h.db.GetUserSessions(ctx, users[0].User.Pk) + if err != nil { + return err + } + + ret := make([]Session, 0, len(dbSessions)) + for _, ses := range dbSessions { + ret = append(ret, MapSession(&ses)) + } + + return c.JSON(http.StatusOK, ret) +} + // @Summary Logout // @Description Delete a session and logout // @Tags sessions diff --git a/auth/tests/sessions.hurl b/auth/tests/sessions.hurl new file mode 100644 index 00000000..6b84362d --- /dev/null +++ b/auth/tests/sessions.hurl @@ -0,0 +1,61 @@ +# Setup first user +POST {{host}}/users +{ + "username": "sessions-user-1", + "password": "password-sessions-user-1", + "email": "sessions-user-1@zoriya.dev" +} +HTTP 201 +[Captures] +token1: jsonpath "$.token" + +GET {{host}}/jwt +Authorization: Bearer {{token1}} +HTTP 200 +[Captures] +jwt1: jsonpath "$.token" + +GET {{host}}/users/me +Authorization: Bearer {{jwt1}} +HTTP 200 +[Captures] +user1Id: jsonpath "$.id" + +# Can list my own sessions +GET {{host}}/sessions +Authorization: Bearer {{jwt1}} +HTTP 200 +[Captures] +session1Id: jsonpath "$[0].id" + +# Setup second user +POST {{host}}/users +{ + "username": "sessions-user-2", + "password": "password-sessions-user-2", + "email": "sessions-user-2@zoriya.dev" +} +HTTP 201 +[Captures] +token2: jsonpath "$.token" + +GET {{host}}/jwt +Authorization: Bearer {{token2}} +HTTP 200 +[Captures] +jwt2: jsonpath "$.token" + +# Cannot list another user's sessions without users.read +GET {{host}}/users/{{user1Id}}/sessions +Authorization: Bearer {{jwt2}} +HTTP 403 + +# Cleanup second user +DELETE {{host}}/users/me +Authorization: Bearer {{jwt2}} +HTTP 200 + +# Cleanup first user +DELETE {{host}}/users/me +Authorization: Bearer {{jwt1}} +HTTP 200 diff --git a/auth/utils.go b/auth/utils.go index c071bf0f..c74c2d23 100644 --- a/auth/utils.go +++ b/auth/utils.go @@ -13,8 +13,8 @@ import ( ) func GetCurrentUserId(c *echo.Context) (uuid.UUID, error) { - user := c.Get("user").(*jwt.Token) - if user == nil { + user, ok := c.Get("user").(*jwt.Token) + if !ok || user == nil { return uuid.UUID{}, echo.NewHTTPError(401, "Unauthorized") } sub, err := user.Claims.GetSubject() @@ -29,8 +29,8 @@ func GetCurrentUserId(c *echo.Context) (uuid.UUID, error) { } func GetCurrentSessionId(c *echo.Context) (uuid.UUID, error) { - user := c.Get("user").(*jwt.Token) - if user == nil { + user, ok := c.Get("user").(*jwt.Token) + if !ok || user == nil { return uuid.UUID{}, echo.NewHTTPError(401, "Unauthorized") } claims, ok := user.Claims.(jwt.MapClaims)