Compare commits

...

7 Commits

Author SHA1 Message Date
53ee156e67 feat: get location util 2025-06-11 18:48:54 +02:00
07a936acc7 feat: device info type 2025-06-11 18:48:48 +02:00
f892f0da24 feat: update session + ps type overriding 2025-06-11 18:48:41 +02:00
38955ee4e6 fix: use the token 2025-06-11 18:48:08 +02:00
7fa7e87e88 feat: token sign with meta data 2025-06-11 18:47:59 +02:00
f085f2e271 feat: create user session 2025-06-11 18:47:45 +02:00
08add259a4 feat: install uasurfer 2025-06-11 18:47:34 +02:00
12 changed files with 218 additions and 62 deletions

1
go.mod
View File

@ -11,6 +11,7 @@ require (
) )
require ( require (
github.com/avct/uasurfer v0.0.0-20250506104815-f2613aa2d406 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect

2
go.sum
View File

@ -1,3 +1,5 @@
github.com/avct/uasurfer v0.0.0-20250506104815-f2613aa2d406 h1:5/KfwL9TS8yNtUSunutqifcSC8rdX9PNdvbSsw/X/lQ=
github.com/avct/uasurfer v0.0.0-20250506104815-f2613aa2d406/go.mod h1:s+GCtuP4kZNxh1WGoqdWI1+PbluBcycrMMWuKQ9e5Nk=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View File

@ -2,11 +2,15 @@ package auth
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/types"
"gitea.local/admin/hspguard/internal/util" "gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web" "gitea.local/admin/hspguard/internal/web"
"github.com/avct/uasurfer"
) )
type LoginParams struct { type LoginParams struct {
@ -47,6 +51,50 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
return return
} }
userAgent := r.UserAgent()
var deviceInfo types.DeviceInfo
parsed := uasurfer.Parse(userAgent)
deviceInfo.Browser = parsed.Browser.Name.StringTrimPrefix()
deviceInfo.BrowserVersion = fmt.Sprintf("%d.%d.%d", parsed.Browser.Version.Major, parsed.Browser.Version.Minor, parsed.Browser.Version.Patch)
deviceInfo.DeviceName = fmt.Sprintf("%s %s", parsed.OS.Platform.StringTrimPrefix(), parsed.OS.Name.StringTrimPrefix())
deviceInfo.DeviceType = parsed.DeviceType.StringTrimPrefix()
deviceInfo.OS = parsed.OS.Platform.StringTrimPrefix()
deviceInfo.OSVersion = fmt.Sprintf("%d.%d.%d", parsed.OS.Version.Major, parsed.OS.Version.Minor, parsed.OS.Version.Patch)
deviceInfo.UserAgent = userAgent
if location, err := util.GetLocation(r.RemoteAddr); err != nil {
log.Println("WARN: Failed to get location from ip (%s): %v\n", r.RemoteAddr, err)
} else {
deviceInfo.Location = fmt.Sprintf("%s, %s, %s", location.Country, location.Region, location.City)
}
serialized, err := json.Marshal(deviceInfo)
if err != nil {
log.Println("ERR: Failed to serialize device info: %v\n", err)
serialized = []byte{'{', '}'}
}
// Create User Session
session, err := h.repo.CreateUserSession(r.Context(), repository.CreateUserSessionParams{
UserID: user.ID,
SessionType: "user",
ExpiresAt: &refresh.ExpiresAt,
LastActive: nil,
IpAddress: &r.RemoteAddr,
UserAgent: &userAgent,
AccessTokenID: &access.ID,
RefreshTokenID: &refresh.ID,
DeviceInfo: serialized,
})
if err != nil {
log.Println("ERR: Failedd to create user session after logging in: %v\n", err)
}
log.Println("INFO: User session created for '%s': %#v\n", user.Email, session)
if err := h.repo.UpdateLastLogin(r.Context(), user.ID); err != nil { if err := h.repo.UpdateLastLogin(r.Context(), user.ID); err != nil {
web.Error(w, "failed to update user's last login", http.StatusInternalServerError) web.Error(w, "failed to update user's last login", http.StatusInternalServerError)
return return
@ -68,8 +116,8 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(Response{ if err := encoder.Encode(Response{
AccessToken: access, AccessToken: access.Token,
RefreshToken: refresh, RefreshToken: refresh.Token,
FullName: user.FullName, FullName: user.FullName,
Email: user.Email, Email: user.Email,
Id: user.ID.String(), Id: user.ID.String(),

View File

@ -74,8 +74,8 @@ func (h *AuthHandler) refreshToken(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(Response{ if err := encoder.Encode(Response{
AccessToken: access, AccessToken: access.Token,
RefreshToken: refresh, RefreshToken: refresh.Token,
}); err != nil { }); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError) web.Error(w, "failed to encode response", http.StatusInternalServerError)
} }

View File

@ -11,6 +11,7 @@ import (
"gitea.local/admin/hspguard/internal/util" "gitea.local/admin/hspguard/internal/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
) )
type AuthHandler struct { type AuthHandler struct {
@ -19,7 +20,24 @@ type AuthHandler struct {
cfg *config.AppConfig cfg *config.AppConfig
} }
func (h *AuthHandler) signTokens(user *repository.User) (string, string, error) { type SignedToken struct {
Token string
ExpiresAt time.Time
ID uuid.UUID
}
func NewSignedToken(token string, expiresAt time.Time, jti uuid.UUID) *SignedToken {
return &SignedToken{
Token: token,
ExpiresAt: expiresAt,
ID: jti,
}
}
func (h *AuthHandler) signTokens(user *repository.User) (*SignedToken, *SignedToken, error) {
accessExpiresAt := time.Now().Add(15 * time.Minute)
accessJTI := uuid.New()
accessClaims := types.UserClaims{ accessClaims := types.UserClaims{
UserEmail: user.Email, UserEmail: user.Email,
IsAdmin: user.IsAdmin, IsAdmin: user.IsAdmin,
@ -27,15 +45,19 @@ func (h *AuthHandler) signTokens(user *repository.User) (string, string, error)
Issuer: h.cfg.Uri, Issuer: h.cfg.Uri,
Subject: user.ID.String(), Subject: user.ID.String(),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), ExpiresAt: jwt.NewNumericDate(accessExpiresAt),
ID: accessJTI.String(),
}, },
} }
accessToken, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey) accessToken, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey)
if err != nil { if err != nil {
return "", "", err return nil, nil, err
} }
refreshExpiresAt := time.Now().Add(30 * 24 * time.Hour)
refreshJTI := uuid.New()
refreshClaims := types.UserClaims{ refreshClaims := types.UserClaims{
UserEmail: user.Email, UserEmail: user.Email,
IsAdmin: user.IsAdmin, IsAdmin: user.IsAdmin,
@ -43,16 +65,17 @@ func (h *AuthHandler) signTokens(user *repository.User) (string, string, error)
Issuer: h.cfg.Uri, Issuer: h.cfg.Uri,
Subject: user.ID.String(), Subject: user.ID.String(),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)), ExpiresAt: jwt.NewNumericDate(refreshExpiresAt),
ID: refreshJTI.String(),
}, },
} }
refreshToken, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey) refreshToken, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey)
if err != nil { if err != nil {
return "", "", err return nil, nil, err
} }
return accessToken, refreshToken, nil return NewSignedToken(accessToken, accessExpiresAt, accessJTI), NewSignedToken(refreshToken, refreshExpiresAt, refreshJTI), nil
} }
func NewAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.AppConfig) *AuthHandler { func NewAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.AppConfig) *AuthHandler {

View File

@ -8,7 +8,6 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
) )
type ApiService struct { type ApiService struct {
@ -27,21 +26,21 @@ type ApiService struct {
} }
type ServiceSession struct { type ServiceSession struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"` ServiceID uuid.UUID `json:"service_id"`
ClientID string `json:"client_id"` ClientID string `json:"client_id"`
UserID *uuid.UUID `json:"user_id"` UserID *uuid.UUID `json:"user_id"`
IssuedAt time.Time `json:"issued_at"` IssuedAt time.Time `json:"issued_at"`
ExpiresAt *time.Time `json:"expires_at"` ExpiresAt *time.Time `json:"expires_at"`
LastActive *time.Time `json:"last_active"` LastActive *time.Time `json:"last_active"`
IpAddress pgtype.Text `json:"ip_address"` IpAddress *string `json:"ip_address"`
UserAgent *string `json:"user_agent"` UserAgent *string `json:"user_agent"`
AccessTokenID *uuid.UUID `json:"access_token_id"` AccessTokenID *uuid.UUID `json:"access_token_id"`
RefreshTokenID *uuid.UUID `json:"refresh_token_id"` RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
RevokedAt *time.Time `json:"revoked_at"` RevokedAt *time.Time `json:"revoked_at"`
Scope *string `json:"scope"` Scope *string `json:"scope"`
Claims []byte `json:"claims"` Claims []byte `json:"claims"`
} }
type User struct { type User struct {
@ -62,17 +61,17 @@ type User struct {
} }
type UserSession struct { type UserSession struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"` UserID uuid.UUID `json:"user_id"`
SessionType string `json:"session_type"` SessionType string `json:"session_type"`
IssuedAt time.Time `json:"issued_at"` IssuedAt time.Time `json:"issued_at"`
ExpiresAt *time.Time `json:"expires_at"` ExpiresAt *time.Time `json:"expires_at"`
LastActive *time.Time `json:"last_active"` LastActive *time.Time `json:"last_active"`
IpAddress pgtype.Text `json:"ip_address"` IpAddress *string `json:"ip_address"`
UserAgent *string `json:"user_agent"` UserAgent *string `json:"user_agent"`
AccessTokenID *uuid.UUID `json:"access_token_id"` AccessTokenID *uuid.UUID `json:"access_token_id"`
RefreshTokenID *uuid.UUID `json:"refresh_token_id"` RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
DeviceInfo []byte `json:"device_info"` DeviceInfo []byte `json:"device_info"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
RevokedAt *time.Time `json:"revoked_at"` RevokedAt *time.Time `json:"revoked_at"`
} }

View File

@ -10,7 +10,6 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
) )
const createServiceSession = `-- name: CreateServiceSession :one const createServiceSession = `-- name: CreateServiceSession :one
@ -27,15 +26,15 @@ RETURNING id, service_id, client_id, user_id, issued_at, expires_at, last_active
` `
type CreateServiceSessionParams struct { type CreateServiceSessionParams struct {
ServiceID uuid.UUID `json:"service_id"` ServiceID uuid.UUID `json:"service_id"`
ClientID string `json:"client_id"` ClientID string `json:"client_id"`
UserID *uuid.UUID `json:"user_id"` UserID *uuid.UUID `json:"user_id"`
ExpiresAt *time.Time `json:"expires_at"` ExpiresAt *time.Time `json:"expires_at"`
LastActive *time.Time `json:"last_active"` LastActive *time.Time `json:"last_active"`
IpAddress pgtype.Text `json:"ip_address"` IpAddress *string `json:"ip_address"`
UserAgent *string `json:"user_agent"` UserAgent *string `json:"user_agent"`
AccessTokenID *uuid.UUID `json:"access_token_id"` AccessTokenID *uuid.UUID `json:"access_token_id"`
RefreshTokenID *uuid.UUID `json:"refresh_token_id"` RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
} }
func (q *Queries) CreateServiceSession(ctx context.Context, arg CreateServiceSessionParams) (ServiceSession, error) { func (q *Queries) CreateServiceSession(ctx context.Context, arg CreateServiceSessionParams) (ServiceSession, error) {

View File

@ -10,7 +10,6 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
) )
const createUserSession = `-- name: CreateUserSession :one const createUserSession = `-- name: CreateUserSession :one
@ -27,15 +26,15 @@ RETURNING id, user_id, session_type, issued_at, expires_at, last_active, ip_addr
` `
type CreateUserSessionParams struct { type CreateUserSessionParams struct {
UserID uuid.UUID `json:"user_id"` UserID uuid.UUID `json:"user_id"`
SessionType string `json:"session_type"` SessionType string `json:"session_type"`
ExpiresAt *time.Time `json:"expires_at"` ExpiresAt *time.Time `json:"expires_at"`
LastActive *time.Time `json:"last_active"` LastActive *time.Time `json:"last_active"`
IpAddress pgtype.Text `json:"ip_address"` IpAddress *string `json:"ip_address"`
UserAgent *string `json:"user_agent"` UserAgent *string `json:"user_agent"`
AccessTokenID *uuid.UUID `json:"access_token_id"` AccessTokenID *uuid.UUID `json:"access_token_id"`
RefreshTokenID *uuid.UUID `json:"refresh_token_id"` RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
DeviceInfo []byte `json:"device_info"` DeviceInfo []byte `json:"device_info"`
} }
func (q *Queries) CreateUserSession(ctx context.Context, arg CreateUserSessionParams) (UserSession, error) { func (q *Queries) CreateUserSession(ctx context.Context, arg CreateUserSessionParams) (UserSession, error) {
@ -233,3 +232,27 @@ func (q *Queries) UpdateSessionLastActive(ctx context.Context, id uuid.UUID) err
_, err := q.db.Exec(ctx, updateSessionLastActive, id) _, err := q.db.Exec(ctx, updateSessionLastActive, id)
return err return err
} }
const updateSessionTokens = `-- name: UpdateSessionTokens :exec
UPDATE user_sessions
SET access_token_id = $2, refresh_token_id = $3, expires_at = $4
WHERE id = $1
AND is_active = TRUE
`
type UpdateSessionTokensParams struct {
ID uuid.UUID `json:"id"`
AccessTokenID *uuid.UUID `json:"access_token_id"`
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
ExpiresAt *time.Time `json:"expires_at"`
}
func (q *Queries) UpdateSessionTokens(ctx context.Context, arg UpdateSessionTokensParams) error {
_, err := q.db.Exec(ctx, updateSessionTokens,
arg.ID,
arg.AccessTokenID,
arg.RefreshTokenID,
arg.ExpiresAt,
)
return err
}

12
internal/types/device.go Normal file
View File

@ -0,0 +1,12 @@
package types
type DeviceInfo struct {
DeviceType string `json:"device_type"`
OS string `json:"os"`
OSVersion string `json:"os_version"`
Browser string `json:"browser"`
BrowserVersion string `json:"browser_version"`
DeviceName string `json:"device_name"`
UserAgent string `json:"user_agent"`
Location string `json:"location"`
}

24
internal/util/location.go Normal file
View File

@ -0,0 +1,24 @@
package util
import (
"encoding/json"
"net/http"
)
type LocationResult struct {
Country string `json:"country"`
Region string `json:"regionName"`
City string `json:"city"`
}
func GetLocation(ip string) (LocationResult, error) {
var loc LocationResult
// Example using ipinfo.io free API
resp, err := http.Get("https://ipinfo.io/" + ip + "/json")
if err != nil {
return loc, err
}
defer resp.Body.Close()
json.NewDecoder(resp.Body).Decode(&loc)
return loc, nil
}

View File

@ -39,6 +39,12 @@ SET last_active = NOW()
WHERE id = $1 WHERE id = $1
AND is_active = TRUE; AND is_active = TRUE;
-- name: UpdateSessionTokens :exec
UPDATE user_sessions
SET access_token_id = $2, refresh_token_id = $3, expires_at = $4
WHERE id = $1
AND is_active = TRUE;
-- name: ListAllSessions :many -- name: ListAllSessions :many
SELECT * FROM user_sessions SELECT * FROM user_sessions
ORDER BY issued_at DESC ORDER BY issued_at DESC

View File

@ -41,20 +41,39 @@ sql:
# ───── text ────────────────────────────────────────── # ───── text ──────────────────────────────────────────
- db_type: "pg_catalog.text" - db_type: "pg_catalog.text"
go_type: { type: "string" } go_type: { type: "string" }
- db_type: "text" # or just "bool"
- db_type: "text"
go_type: { type: "string" } go_type: { type: "string" }
- db_type: "pg_catalog.text" - db_type: "pg_catalog.text"
nullable: true nullable: true
go_type: go_type:
type: "string" type: "string"
pointer: true # ⇒ *bool for NULLable columns pointer: true
- db_type: "text" - db_type: "text"
nullable: true nullable: true
go_type: go_type:
type: "string" type: "string"
pointer: true # ⇒ *bool for NULLable columns pointer: true
- db_type: "pg_catalog.varchar"
go_type: { type: "string" }
- db_type: "varchar"
go_type: { type: "string" }
- db_type: "pg_catalog.varchar"
nullable: true
go_type:
type: "string"
pointer: true
- db_type: "varchar"
nullable: true
go_type:
type: "string"
pointer: true
# ───── timestamp (WITHOUT TZ) ──────────────────────── # ───── timestamp (WITHOUT TZ) ────────────────────────
- db_type: "pg_catalog.timestamp" # or "timestamp" - db_type: "pg_catalog.timestamp" # or "timestamp"