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 (
github.com/avct/uasurfer v0.0.0-20250506104815-f2613aa2d406 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View File

@ -2,11 +2,15 @@ package auth
import (
"encoding/json"
"fmt"
"log"
"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/web"
"github.com/avct/uasurfer"
)
type LoginParams struct {
@ -47,6 +51,50 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
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 {
web.Error(w, "failed to update user's last login", http.StatusInternalServerError)
return
@ -68,8 +116,8 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(Response{
AccessToken: access,
RefreshToken: refresh,
AccessToken: access.Token,
RefreshToken: refresh.Token,
FullName: user.FullName,
Email: user.Email,
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")
if err := encoder.Encode(Response{
AccessToken: access,
RefreshToken: refresh,
AccessToken: access.Token,
RefreshToken: refresh.Token,
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}

View File

@ -11,6 +11,7 @@ import (
"gitea.local/admin/hspguard/internal/util"
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
type AuthHandler struct {
@ -19,7 +20,24 @@ type AuthHandler struct {
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{
UserEmail: user.Email,
IsAdmin: user.IsAdmin,
@ -27,15 +45,19 @@ func (h *AuthHandler) signTokens(user *repository.User) (string, string, error)
Issuer: h.cfg.Uri,
Subject: user.ID.String(),
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)
if err != nil {
return "", "", err
return nil, nil, err
}
refreshExpiresAt := time.Now().Add(30 * 24 * time.Hour)
refreshJTI := uuid.New()
refreshClaims := types.UserClaims{
UserEmail: user.Email,
IsAdmin: user.IsAdmin,
@ -43,16 +65,17 @@ func (h *AuthHandler) signTokens(user *repository.User) (string, string, error)
Issuer: h.cfg.Uri,
Subject: user.ID.String(),
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)
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 {

View File

@ -8,7 +8,6 @@ import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type ApiService struct {
@ -27,21 +26,21 @@ type ApiService struct {
}
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"`
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 *string `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 {
@ -62,17 +61,17 @@ type User struct {
}
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"`
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 *string `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"`
}

View File

@ -10,7 +10,6 @@ import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
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 {
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"`
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 *string `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) {

View File

@ -10,7 +10,6 @@ import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
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 {
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"`
UserID uuid.UUID `json:"user_id"`
SessionType string `json:"session_type"`
ExpiresAt *time.Time `json:"expires_at"`
LastActive *time.Time `json:"last_active"`
IpAddress *string `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) {
@ -233,3 +232,27 @@ func (q *Queries) UpdateSessionLastActive(ctx context.Context, id uuid.UUID) err
_, err := q.db.Exec(ctx, updateSessionLastActive, id)
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
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
SELECT * FROM user_sessions
ORDER BY issued_at DESC

View File

@ -41,20 +41,39 @@ sql:
# ───── text ──────────────────────────────────────────
- db_type: "pg_catalog.text"
go_type: { type: "string" }
- db_type: "text" # or just "bool"
- db_type: "text"
go_type: { type: "string" }
- db_type: "pg_catalog.text"
nullable: true
go_type:
type: "string"
pointer: true # ⇒ *bool for NULLable columns
pointer: true
- db_type: "text"
nullable: true
go_type:
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) ────────────────────────
- db_type: "pg_catalog.timestamp" # or "timestamp"