diff --git a/internal/repository/models.go b/internal/repository/models.go index 8ebd95f..97df882 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -8,6 +8,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" ) type ApiService struct { @@ -25,6 +26,24 @@ type ApiService struct { IconUrl *string `json:"icon_url"` } +type ServiceSession struct { + ID uuid.UUID `json:"id"` + ServiceID uuid.UUID `json:"service_id"` + ClientID string `json:"client_id"` + UserID *uuid.UUID `json:"user_id"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt *time.Time `json:"expires_at"` + LastActive *time.Time `json:"last_active"` + IpAddress pgtype.Text `json:"ip_address"` + UserAgent *string `json:"user_agent"` + AccessTokenID *uuid.UUID `json:"access_token_id"` + RefreshTokenID *uuid.UUID `json:"refresh_token_id"` + IsActive bool `json:"is_active"` + RevokedAt *time.Time `json:"revoked_at"` + Scope *string `json:"scope"` + Claims []byte `json:"claims"` +} + type User struct { ID uuid.UUID `json:"id"` Email string `json:"email"` @@ -41,3 +60,19 @@ type User struct { AvatarVerified bool `json:"avatar_verified"` Verified bool `json:"verified"` } + +type UserSession struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + SessionType string `json:"session_type"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt *time.Time `json:"expires_at"` + LastActive *time.Time `json:"last_active"` + IpAddress pgtype.Text `json:"ip_address"` + UserAgent *string `json:"user_agent"` + AccessTokenID *uuid.UUID `json:"access_token_id"` + RefreshTokenID *uuid.UUID `json:"refresh_token_id"` + DeviceInfo []byte `json:"device_info"` + IsActive bool `json:"is_active"` + RevokedAt *time.Time `json:"revoked_at"` +} diff --git a/internal/repository/service_sessions.sql.go b/internal/repository/service_sessions.sql.go new file mode 100644 index 0000000..d910340 --- /dev/null +++ b/internal/repository/service_sessions.sql.go @@ -0,0 +1,288 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: service_sessions.sql + +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const createServiceSession = `-- name: CreateServiceSession :one +INSERT INTO service_sessions ( + service_id, client_id, user_id, issued_at, expires_at, last_active, + ip_address, user_agent, access_token_id, refresh_token_id, + is_active, scope, claims +) VALUES ( + $1, $2, $3, NOW(), $4, $5, + $6, $7, $8, $9, + TRUE, $8, $9 +) +RETURNING id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims +` + +type CreateServiceSessionParams struct { + ServiceID uuid.UUID `json:"service_id"` + ClientID string `json:"client_id"` + UserID *uuid.UUID `json:"user_id"` + ExpiresAt *time.Time `json:"expires_at"` + LastActive *time.Time `json:"last_active"` + IpAddress pgtype.Text `json:"ip_address"` + UserAgent *string `json:"user_agent"` + AccessTokenID *uuid.UUID `json:"access_token_id"` + RefreshTokenID *uuid.UUID `json:"refresh_token_id"` +} + +func (q *Queries) CreateServiceSession(ctx context.Context, arg CreateServiceSessionParams) (ServiceSession, error) { + row := q.db.QueryRow(ctx, createServiceSession, + arg.ServiceID, + arg.ClientID, + arg.UserID, + arg.ExpiresAt, + arg.LastActive, + arg.IpAddress, + arg.UserAgent, + arg.AccessTokenID, + arg.RefreshTokenID, + ) + var i ServiceSession + err := row.Scan( + &i.ID, + &i.ServiceID, + &i.ClientID, + &i.UserID, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.IsActive, + &i.RevokedAt, + &i.Scope, + &i.Claims, + ) + return i, err +} + +const getServiceSessionByAccessJTI = `-- name: GetServiceSessionByAccessJTI :one +SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions +WHERE access_token_id = $1 + AND is_active = TRUE +` + +func (q *Queries) GetServiceSessionByAccessJTI(ctx context.Context, accessTokenID *uuid.UUID) (ServiceSession, error) { + row := q.db.QueryRow(ctx, getServiceSessionByAccessJTI, accessTokenID) + var i ServiceSession + err := row.Scan( + &i.ID, + &i.ServiceID, + &i.ClientID, + &i.UserID, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.IsActive, + &i.RevokedAt, + &i.Scope, + &i.Claims, + ) + return i, err +} + +const getServiceSessionByRefreshJTI = `-- name: GetServiceSessionByRefreshJTI :one +SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions +WHERE refresh_token_id = $1 + AND is_active = TRUE +` + +func (q *Queries) GetServiceSessionByRefreshJTI(ctx context.Context, refreshTokenID *uuid.UUID) (ServiceSession, error) { + row := q.db.QueryRow(ctx, getServiceSessionByRefreshJTI, refreshTokenID) + var i ServiceSession + err := row.Scan( + &i.ID, + &i.ServiceID, + &i.ClientID, + &i.UserID, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.IsActive, + &i.RevokedAt, + &i.Scope, + &i.Claims, + ) + return i, err +} + +const listActiveServiceSessionsByClient = `-- name: ListActiveServiceSessionsByClient :many +SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions +WHERE client_id = $1 + AND is_active = TRUE +ORDER BY issued_at DESC +` + +func (q *Queries) ListActiveServiceSessionsByClient(ctx context.Context, clientID string) ([]ServiceSession, error) { + rows, err := q.db.Query(ctx, listActiveServiceSessionsByClient, clientID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ServiceSession + for rows.Next() { + var i ServiceSession + if err := rows.Scan( + &i.ID, + &i.ServiceID, + &i.ClientID, + &i.UserID, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.IsActive, + &i.RevokedAt, + &i.Scope, + &i.Claims, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listActiveServiceSessionsByUser = `-- name: ListActiveServiceSessionsByUser :many +SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions +WHERE user_id = $1 + AND is_active = TRUE +ORDER BY issued_at DESC +` + +func (q *Queries) ListActiveServiceSessionsByUser(ctx context.Context, userID *uuid.UUID) ([]ServiceSession, error) { + rows, err := q.db.Query(ctx, listActiveServiceSessionsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ServiceSession + for rows.Next() { + var i ServiceSession + if err := rows.Scan( + &i.ID, + &i.ServiceID, + &i.ClientID, + &i.UserID, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.IsActive, + &i.RevokedAt, + &i.Scope, + &i.Claims, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAllServiceSessions = `-- name: ListAllServiceSessions :many +SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions +ORDER BY issued_at DESC +LIMIT $1 OFFSET $2 +` + +type ListAllServiceSessionsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListAllServiceSessions(ctx context.Context, arg ListAllServiceSessionsParams) ([]ServiceSession, error) { + rows, err := q.db.Query(ctx, listAllServiceSessions, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ServiceSession + for rows.Next() { + var i ServiceSession + if err := rows.Scan( + &i.ID, + &i.ServiceID, + &i.ClientID, + &i.UserID, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.IsActive, + &i.RevokedAt, + &i.Scope, + &i.Claims, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const revokeServiceSession = `-- name: RevokeServiceSession :exec +UPDATE service_sessions +SET is_active = FALSE, + revoked_at = NOW() +WHERE id = $1 + AND is_active = TRUE +` + +func (q *Queries) RevokeServiceSession(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, revokeServiceSession, id) + return err +} + +const updateServiceSessionLastActive = `-- name: UpdateServiceSessionLastActive :exec +UPDATE service_sessions +SET last_active = NOW() +WHERE id = $1 + AND is_active = TRUE +` + +func (q *Queries) UpdateServiceSessionLastActive(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, updateServiceSessionLastActive, id) + return err +} diff --git a/internal/repository/user_sessions.sql.go b/internal/repository/user_sessions.sql.go new file mode 100644 index 0000000..e8b5cd5 --- /dev/null +++ b/internal/repository/user_sessions.sql.go @@ -0,0 +1,235 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: user_sessions.sql + +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const createUserSession = `-- name: CreateUserSession :one +INSERT INTO user_sessions ( + user_id, session_type, issued_at, expires_at, last_active, + ip_address, user_agent, access_token_id, refresh_token_id, + device_info, is_active +) VALUES ( + $1, $2, NOW(), $3, $4, + $5, $6, $7, $8, + $9, TRUE +) +RETURNING id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at +` + +type CreateUserSessionParams struct { + UserID uuid.UUID `json:"user_id"` + SessionType string `json:"session_type"` + ExpiresAt *time.Time `json:"expires_at"` + LastActive *time.Time `json:"last_active"` + IpAddress pgtype.Text `json:"ip_address"` + UserAgent *string `json:"user_agent"` + AccessTokenID *uuid.UUID `json:"access_token_id"` + RefreshTokenID *uuid.UUID `json:"refresh_token_id"` + DeviceInfo []byte `json:"device_info"` +} + +func (q *Queries) CreateUserSession(ctx context.Context, arg CreateUserSessionParams) (UserSession, error) { + row := q.db.QueryRow(ctx, createUserSession, + arg.UserID, + arg.SessionType, + arg.ExpiresAt, + arg.LastActive, + arg.IpAddress, + arg.UserAgent, + arg.AccessTokenID, + arg.RefreshTokenID, + arg.DeviceInfo, + ) + var i UserSession + err := row.Scan( + &i.ID, + &i.UserID, + &i.SessionType, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.DeviceInfo, + &i.IsActive, + &i.RevokedAt, + ) + return i, err +} + +const getUserSessionByAccessJTI = `-- name: GetUserSessionByAccessJTI :one +SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions +WHERE access_token_id = $1 + AND is_active = TRUE +` + +func (q *Queries) GetUserSessionByAccessJTI(ctx context.Context, accessTokenID *uuid.UUID) (UserSession, error) { + row := q.db.QueryRow(ctx, getUserSessionByAccessJTI, accessTokenID) + var i UserSession + err := row.Scan( + &i.ID, + &i.UserID, + &i.SessionType, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.DeviceInfo, + &i.IsActive, + &i.RevokedAt, + ) + return i, err +} + +const getUserSessionByRefreshJTI = `-- name: GetUserSessionByRefreshJTI :one +SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions +WHERE refresh_token_id = $1 + AND is_active = TRUE +` + +func (q *Queries) GetUserSessionByRefreshJTI(ctx context.Context, refreshTokenID *uuid.UUID) (UserSession, error) { + row := q.db.QueryRow(ctx, getUserSessionByRefreshJTI, refreshTokenID) + var i UserSession + err := row.Scan( + &i.ID, + &i.UserID, + &i.SessionType, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.DeviceInfo, + &i.IsActive, + &i.RevokedAt, + ) + return i, err +} + +const listActiveUserSessions = `-- name: ListActiveUserSessions :many +SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions +WHERE user_id = $1 + AND is_active = TRUE +ORDER BY issued_at DESC +` + +func (q *Queries) ListActiveUserSessions(ctx context.Context, userID uuid.UUID) ([]UserSession, error) { + rows, err := q.db.Query(ctx, listActiveUserSessions, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UserSession + for rows.Next() { + var i UserSession + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.SessionType, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.DeviceInfo, + &i.IsActive, + &i.RevokedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAllSessions = `-- name: ListAllSessions :many +SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions +ORDER BY issued_at DESC +LIMIT $1 OFFSET $2 +` + +type ListAllSessionsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListAllSessions(ctx context.Context, arg ListAllSessionsParams) ([]UserSession, error) { + rows, err := q.db.Query(ctx, listAllSessions, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UserSession + for rows.Next() { + var i UserSession + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.SessionType, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.DeviceInfo, + &i.IsActive, + &i.RevokedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const revokeUserSession = `-- name: RevokeUserSession :exec +UPDATE user_sessions +SET is_active = FALSE, + revoked_at = NOW() +WHERE id = $1 + AND is_active = TRUE +` + +func (q *Queries) RevokeUserSession(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, revokeUserSession, id) + return err +} + +const updateSessionLastActive = `-- name: UpdateSessionLastActive :exec +UPDATE user_sessions +SET last_active = NOW() +WHERE id = $1 + AND is_active = TRUE +` + +func (q *Queries) UpdateSessionLastActive(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, updateSessionLastActive, id) + return err +} diff --git a/migrations/00011_add_user_sessions_table.sql b/migrations/00011_add_user_sessions_table.sql index fcb45e4..1f1f994 100644 --- a/migrations/00011_add_user_sessions_table.sql +++ b/migrations/00011_add_user_sessions_table.sql @@ -15,7 +15,8 @@ CREATE TABLE user_sessions ( TIME ZONE, ip_address VARCHAR(45), -- supports IPv4/IPv6 user_agent TEXT, - refresh_token TEXT, + access_token_id UUID, + refresh_token_id UUID, device_info JSONB, -- optional: structured info (browser, OS, etc.) is_active BOOLEAN NOT NULL DEFAULT TRUE, revoked_at TIMESTAMP diff --git a/migrations/00012_add_service_sessions.sql b/migrations/00012_add_service_sessions.sql index e2144de..87b1feb 100644 --- a/migrations/00012_add_service_sessions.sql +++ b/migrations/00012_add_service_sessions.sql @@ -16,7 +16,8 @@ CREATE TABLE service_sessions ( TIME ZONE, ip_address VARCHAR(45), user_agent TEXT, - refresh_token TEXT, + access_token_id UUID, + refresh_token_id UUID, is_active BOOLEAN NOT NULL DEFAULT TRUE, revoked_at TIMESTAMP WITH diff --git a/queries/service_sessions.sql b/queries/service_sessions.sql index 7064fba..378dd79 100644 --- a/queries/service_sessions.sql +++ b/queries/service_sessions.sql @@ -1,10 +1,12 @@ -- name: CreateServiceSession :one INSERT INTO service_sessions ( - client_id, user_id, issued_at, expires_at, last_active, - ip_address, user_agent, is_active, scope, claims + service_id, client_id, user_id, issued_at, expires_at, last_active, + ip_address, user_agent, access_token_id, refresh_token_id, + is_active, scope, claims ) VALUES ( - $1, $2, NOW(), $3, $4, - $5, $6, $6, TRUE, $7, $8 + $1, $2, $3, NOW(), $4, $5, + $6, $7, $8, $9, + TRUE, $8, $9 ) RETURNING *; @@ -20,9 +22,14 @@ WHERE user_id = $1 AND is_active = TRUE ORDER BY issued_at DESC; --- name: GetServiceSessionByToken :one +-- name: GetServiceSessionByAccessJTI :one SELECT * FROM service_sessions -WHERE refresh_token = $1 +WHERE access_token_id = $1 + AND is_active = TRUE; + +-- name: GetServiceSessionByRefreshJTI :one +SELECT * FROM service_sessions +WHERE refresh_token_id = $1 AND is_active = TRUE; -- name: RevokeServiceSession :exec diff --git a/queries/user_sessions.sql b/queries/user_sessions.sql index fd07a89..0125920 100644 --- a/queries/user_sessions.sql +++ b/queries/user_sessions.sql @@ -1,12 +1,12 @@ -- name: CreateUserSession :one INSERT INTO user_sessions ( user_id, session_type, issued_at, expires_at, last_active, - ip_address, user_agent, + ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active ) VALUES ( $1, $2, NOW(), $3, $4, - $5, $6, - $7, TRUE + $5, $6, $7, $8, + $9, TRUE ) RETURNING *; @@ -16,9 +16,14 @@ WHERE user_id = $1 AND is_active = TRUE ORDER BY issued_at DESC; --- name: GetUserSessionByToken :one +-- name: GetUserSessionByAccessJTI :one SELECT * FROM user_sessions -WHERE refresh_token = $1 +WHERE access_token_id = $1 + AND is_active = TRUE; + +-- name: GetUserSessionByRefreshJTI :one +SELECT * FROM user_sessions +WHERE refresh_token_id = $1 AND is_active = TRUE; -- name: RevokeUserSession :exec