feat: Add Sliding expiration for user_sessions

The user_session is valid for 24 hours, but the session will be bumped if it has been over an hour since the last update. An hour was set as a deadline, so that we would not update the user_session rows too often.
This commit is contained in:
Leo 2026-08-17 23:47:11 +03:00
parent 1d70b3a6d3
commit 43219268e9
13 changed files with 140 additions and 58 deletions

View file

@ -15,7 +15,9 @@ A simple Go backend with authentication using native `net/http` and PostgreSQL.
- Go 1.24+ - Go 1.24+
- PostgreSQL - PostgreSQL
- [goose](https://github.com/pressly/goose) for migrations, [air](https://github.com/air-verse/air) for live reload (optional): - [goose](https://github.com/pressly/goose) for migrationns
- [sqlc](https://github.com/sqlc-dev/sqlc) for generating typed SQL queries
- [air](https://github.com/air-verse/air) for live reload (optional)
```bash ```bash
go install github.com/pressly/goose/v3/cmd/goose@latest go install github.com/pressly/goose/v3/cmd/goose@latest
go install github.com/air-verse/air@latest go install github.com/air-verse/air@latest
@ -24,6 +26,7 @@ A simple Go backend with authentication using native `net/http` and PostgreSQL.
## Setup ## Setup
1. Copy environment variables: 1. Copy environment variables:
```bash ```bash
cp .env.example .env cp .env.example .env
``` ```
@ -31,11 +34,13 @@ A simple Go backend with authentication using native `net/http` and PostgreSQL.
2. Update `.env` with your database credentials (both the `DB_*` and `GOOSE_*` values). 2. Update `.env` with your database credentials (both the `DB_*` and `GOOSE_*` values).
3. Install dependencies: 3. Install dependencies:
```bash ```bash
go mod tidy go mod tidy
``` ```
4. Run database migrations: 4. Run database migrations:
```bash ```bash
./scripts/goose.sh up ./scripts/goose.sh up
``` ```
@ -69,4 +74,4 @@ Query codegen is configured in `sqlc.yaml` (engine `postgresql`, schema read fro
## Database Schema ## Database Schema
- `user_auth` - User accounts with Argon2 password hashes - `user_auth` - User accounts with Argon2 password hashes
- `user_session` - User sessions with SHA-256 hashed token secrets - `user_session` - User sessions with SHA-256 hashed token secrets

View file

@ -14,12 +14,10 @@ import (
) )
func main() { func main() {
// Load .env file
if err := godotenv.Load(); err != nil { if err := godotenv.Load(); err != nil {
log.Println("No .env file found") log.Println("No .env file found")
} }
// Connect to database
db, err := database.Connect() db, err := database.Connect()
if err != nil { if err != nil {
log.Fatal("Failed to connect to database:", err) log.Fatal("Failed to connect to database:", err)
@ -33,10 +31,8 @@ func main() {
handler := handlers.NewHandler(userService, authService) handler := handlers.NewHandler(userService, authService)
// Create router
mux := http.NewServeMux() mux := http.NewServeMux()
// Routes
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./ui/static")))) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./ui/static"))))
mux.HandleFunc("GET /{$}", handler.HomeHandler) mux.HandleFunc("GET /{$}", handler.HomeHandler)
@ -48,10 +44,8 @@ func main() {
mux.HandleFunc("POST /api/logout", handler.LogoutHandler) mux.HandleFunc("POST /api/logout", handler.LogoutHandler)
mux.HandleFunc("POST /api/signup", handler.SignUpHandler) mux.HandleFunc("POST /api/signup", handler.SignUpHandler)
// Apply middleware (CORS first, then logging)
muxMiddleware := handlers.LoggingMiddleware(handlers.CORSMiddleware(mux)) muxMiddleware := handlers.LoggingMiddleware(handlers.CORSMiddleware(mux))
// Start server
port := getEnv("PORT", "8080") port := getEnv("PORT", "8080")
log.Printf("Server starting on port %s", port) log.Printf("Server starting on port %s", port)

View file

@ -3,11 +3,13 @@ package auth
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"net/http"
"strings" "strings"
"time" "time"
) )
const sessionExpiresInSeconds = 60 * 60 * 24 // 1 day const sessionExpiresInSeconds = 60 * 60 * 24 // 1 day
const activityCheckInterval = 60 * 60 // 1 hour
func GenerateSecureRandomString() (string, error) { func GenerateSecureRandomString() (string, error) {
// Human readable alphabet (a-z, 0-9 without l, o, 0, 1 to avoid confusion) // Human readable alphabet (a-z, 0-9 without l, o, 0, 1 to avoid confusion)
@ -34,6 +36,35 @@ func HashSecret(secret string) []byte {
return hash[:] return hash[:]
} }
func CheckNeedForBump(lastVerified time.Time) bool {
return time.Since(lastVerified).Seconds() >= activityCheckInterval
}
func CheckExpiration(expirarion time.Time) bool { func CheckExpiration(expirarion time.Time) bool {
return time.Since(expirarion).Seconds() >= sessionExpiresInSeconds return time.Since(expirarion).Seconds() >= sessionExpiresInSeconds
} }
func NewSessionCookie(token string, deleted bool) *http.Cookie {
cookie := &http.Cookie{
Name: "session",
Value: token,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: sessionExpiresInSeconds,
Path: "/",
}
if deleted {
cookie = &http.Cookie{
Name: "session",
Value: "",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
Path: "/",
}
}
return cookie
}

View file

@ -1,12 +1,17 @@
-- name: SelectSessionById :one -- name: SelectSessionById :one
SELECT id, secret_hash, user_id, created_at SELECT id, secret_hash, user_id, last_verified_at, created_at, updated_at
FROM user_session FROM user_session
WHERE id = $1; WHERE id = $1;
-- name: InsertSession :one -- name: InsertSession :one
INSERT INTO user_session (id, secret_hash, user_id) INSERT INTO user_session (id, secret_hash, user_id)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
RETURNING id, secret_hash, user_id, created_at; RETURNING id, secret_hash, user_id, last_verified_at, created_at, updated_at;
-- name: UpdateSessionVerifiedAt :one
UPDATE user_session SET last_verified_at = NOW()
WHERE id = $1
RETURNING id, secret_hash, user_id, last_verified_at, created_at, updated_at;
-- name: DeleteSessionById :exec -- name: DeleteSessionById :exec
DELETE FROM user_session WHERE id = $1; DELETE FROM user_session WHERE id = $1;

View file

@ -18,8 +18,10 @@ type UserAuth struct {
} }
type UserSession struct { type UserSession struct {
ID string ID string
SecretHash []byte SecretHash []byte
UserID string UserID string
CreatedAt time.Time LastVerifiedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
} }

View file

@ -21,7 +21,7 @@ func (q *Queries) DeleteSessionById(ctx context.Context, id string) error {
const insertSession = `-- name: InsertSession :one const insertSession = `-- name: InsertSession :one
INSERT INTO user_session (id, secret_hash, user_id) INSERT INTO user_session (id, secret_hash, user_id)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
RETURNING id, secret_hash, user_id, created_at RETURNING id, secret_hash, user_id, last_verified_at, created_at, updated_at
` `
type InsertSessionParams struct { type InsertSessionParams struct {
@ -37,13 +37,15 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (U
&i.ID, &i.ID,
&i.SecretHash, &i.SecretHash,
&i.UserID, &i.UserID,
&i.LastVerifiedAt,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt,
) )
return i, err return i, err
} }
const selectSessionById = `-- name: SelectSessionById :one const selectSessionById = `-- name: SelectSessionById :one
SELECT id, secret_hash, user_id, created_at SELECT id, secret_hash, user_id, last_verified_at, created_at, updated_at
FROM user_session FROM user_session
WHERE id = $1 WHERE id = $1
` `
@ -55,7 +57,29 @@ func (q *Queries) SelectSessionById(ctx context.Context, id string) (UserSession
&i.ID, &i.ID,
&i.SecretHash, &i.SecretHash,
&i.UserID, &i.UserID,
&i.LastVerifiedAt,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const updateSessionVerifiedAt = `-- name: UpdateSessionVerifiedAt :one
UPDATE user_session SET last_verified_at = NOW()
WHERE id = $1
RETURNING id, secret_hash, user_id, last_verified_at, created_at, updated_at
`
func (q *Queries) UpdateSessionVerifiedAt(ctx context.Context, id string) (UserSession, error) {
row := q.db.QueryRowContext(ctx, updateSessionVerifiedAt, id)
var i UserSession
err := row.Scan(
&i.ID,
&i.SecretHash,
&i.UserID,
&i.LastVerifiedAt,
&i.CreatedAt,
&i.UpdatedAt,
) )
return i, err return i, err
} }

View file

@ -2,6 +2,7 @@ package handlers
import ( import (
"context" "context"
"go-backend/internal/auth"
"go-backend/internal/database/sqlc" "go-backend/internal/database/sqlc"
"go-backend/internal/services" "go-backend/internal/services"
"log" "log"
@ -21,7 +22,7 @@ type UserService interface {
type AuthService interface { type AuthService interface {
CreateSession(ctx context.Context, userID string) (*services.SessionWithToken, error) CreateSession(ctx context.Context, userID string) (*services.SessionWithToken, error)
ValidateSessionToken(ctx context.Context, token string) (*sqlc.UserSession, error) ValidateSessionToken(ctx context.Context, token string) (*sqlc.UserSession, bool, error)
DeleteSession(ctx context.Context, token string) error DeleteSession(ctx context.Context, token string) error
} }
@ -45,7 +46,7 @@ func (h *Handler) HomeHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
session, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value) session, bump, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
if err != nil { if err != nil {
log.Print(err.Error()) log.Print(err.Error())
@ -58,6 +59,10 @@ func (h *Handler) HomeHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
if bump {
http.SetCookie(w, auth.NewSessionCookie(cookie.Value, false))
}
files := []string{ files := []string{
"./ui/html/base.tmpl", "./ui/html/base.tmpl",
"./ui/html/pages/index.tmpl", "./ui/html/pages/index.tmpl",
@ -81,7 +86,7 @@ func (h *Handler) LoginPageHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session") cookie, err := r.Cookie("session")
if cookie != nil { if cookie != nil {
session, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value) session, bump, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
if err != nil { if err != nil {
log.Print(err.Error()) log.Print(err.Error())
@ -89,6 +94,10 @@ func (h *Handler) LoginPageHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
if bump {
http.SetCookie(w, auth.NewSessionCookie(cookie.Value, false))
}
if session != nil { if session != nil {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return
@ -118,7 +127,7 @@ func (h *Handler) SignUpPageHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session") cookie, err := r.Cookie("session")
if cookie != nil { if cookie != nil {
session, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value) session, bump, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
if err != nil { if err != nil {
log.Print(err.Error()) log.Print(err.Error())
@ -126,6 +135,10 @@ func (h *Handler) SignUpPageHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
if bump {
http.SetCookie(w, auth.NewSessionCookie(cookie.Value, false))
}
if session != nil { if session != nil {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return

View file

@ -52,14 +52,7 @@ func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
cookie := &http.Cookie{ cookie := auth.NewSessionCookie(session.Token, false)
Name: "session",
Value: session.Token,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400, // 24 hours
Path: "/",
}
http.SetCookie(w, cookie) http.SetCookie(w, cookie)
w.Header().Set("HX-Redirect", "/") w.Header().Set("HX-Redirect", "/")
@ -82,16 +75,7 @@ func (h *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
cookie = &http.Cookie{ http.SetCookie(w, auth.NewSessionCookie("", true))
Name: "session",
Value: "",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
Path: "/",
}
http.SetCookie(w, cookie)
w.Header().Set("HX-Redirect", "/") w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)

View file

@ -64,14 +64,7 @@ func (h *Handler) SignUpHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
cookie := &http.Cookie{ cookie := auth.NewSessionCookie(session.Token, false)
Name: "session",
Value: session.Token,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400, // 24 hours
Path: "/",
}
http.SetCookie(w, cookie) http.SetCookie(w, cookie)
w.Header().Set("HX-Redirect", "/") w.Header().Set("HX-Redirect", "/")

View file

@ -2,6 +2,7 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"go-backend/internal/auth"
"net/http" "net/http"
) )
@ -25,16 +26,22 @@ func (h *Handler) UserHandler(w http.ResponseWriter, r *http.Request) {
} }
// Validate session // Validate session
session, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value) session, bump, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
if err != nil { if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
if session == nil { if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
if bump {
http.SetCookie(w, auth.NewSessionCookie(cookie.Value, false))
}
// Get user info // Get user info
user, err := h.users.FindByID(r.Context(), session.UserID) user, err := h.users.FindByID(r.Context(), session.UserID)
if err != nil { if err != nil {

View file

@ -58,10 +58,10 @@ func (s *AuthService) CreateSession(ctx context.Context, userID string) (*Sessio
} }
func (s *AuthService) ValidateSessionToken(ctx context.Context, token string) (*sqlc.UserSession, error) { func (s *AuthService) ValidateSessionToken(ctx context.Context, token string) (*sqlc.UserSession, bool, error) {
tokenParts := strings.Split(token, ".") tokenParts := strings.Split(token, ".")
if len(tokenParts) != 2 { if len(tokenParts) != 2 {
return nil, nil return nil, false, nil
} }
sessionID := tokenParts[0] sessionID := tokenParts[0]
@ -71,28 +71,38 @@ func (s *AuthService) ValidateSessionToken(ctx context.Context, token string) (*
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, false, nil
} }
return nil, err return nil, false, err
} }
if auth.CheckExpiration(session.CreatedAt) { if auth.CheckExpiration(session.LastVerifiedAt) {
err = s.queries.DeleteSessionById(ctx, sessionID) err = s.queries.DeleteSessionById(ctx, sessionID)
if err != nil { if err != nil {
return nil, err return nil, false, err
} }
return nil, nil return nil, false, nil
} }
tokenSecretHash := auth.HashSecret(sessionSecret) tokenSecretHash := auth.HashSecret(sessionSecret)
if subtle.ConstantTimeCompare(tokenSecretHash, session.SecretHash) != 1 { if subtle.ConstantTimeCompare(tokenSecretHash, session.SecretHash) != 1 {
return nil, nil return nil, false, nil
} }
return &session, nil if auth.CheckNeedForBump(session.LastVerifiedAt) {
session, err = s.queries.UpdateSessionVerifiedAt(ctx, sessionID)
if err != nil {
return nil, false, err
}
return &session, true, nil
}
return &session, false, nil
} }
func (s *AuthService) DeleteSession(ctx context.Context, token string) error { func (s *AuthService) DeleteSession(ctx context.Context, token string) error {

View file

@ -15,7 +15,9 @@ CREATE TABLE IF NOT EXISTS user_session (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
secret_hash BYTEA NOT NULL, secret_hash BYTEA NOT NULL,
user_id TEXT NOT NULL REFERENCES user_auth (id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES user_auth (id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() last_verified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
CREATE INDEX IF NOT EXISTS idx_user_session_user_id ON user_session (user_id); CREATE INDEX IF NOT EXISTS idx_user_session_user_id ON user_session (user_id);
@ -34,6 +36,11 @@ CREATE TRIGGER user_auth_set_updated_at
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION set_updated_at(); EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER user_session_set_updated_at
BEFORE UPDATE ON user_session
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- +goose Down -- +goose Down
DROP TRIGGER IF EXISTS user_auth_set_updated_at ON user_auth; DROP TRIGGER IF EXISTS user_auth_set_updated_at ON user_auth;

7
todo.txt Normal file
View file

@ -0,0 +1,7 @@
[ ] Add secure TLS to cookies
[ ] Add confirm email
[ ] Add OTP (Scaleway?)
[ ] Add OAuth (Google?)
[ ] Add profile - patch user
[ ] Add an admin role and admin panel
[ ] Add forgotten pw flow