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+
- 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
go install github.com/pressly/goose/v3/cmd/goose@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
1. Copy environment variables:
```bash
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).
3. Install dependencies:
```bash
go mod tidy
```
4. Run database migrations:
```bash
./scripts/goose.sh up
```

View file

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

View file

@ -3,11 +3,13 @@ package auth
import (
"crypto/rand"
"crypto/sha256"
"net/http"
"strings"
"time"
)
const sessionExpiresInSeconds = 60 * 60 * 24 // 1 day
const activityCheckInterval = 60 * 60 // 1 hour
func GenerateSecureRandomString() (string, error) {
// 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[:]
}
func CheckNeedForBump(lastVerified time.Time) bool {
return time.Since(lastVerified).Seconds() >= activityCheckInterval
}
func CheckExpiration(expirarion time.Time) bool {
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
SELECT id, secret_hash, user_id, created_at
SELECT id, secret_hash, user_id, last_verified_at, created_at, updated_at
FROM user_session
WHERE id = $1;
-- name: InsertSession :one
INSERT INTO user_session (id, secret_hash, user_id)
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
DELETE FROM user_session WHERE id = $1;

View file

@ -21,5 +21,7 @@ type UserSession struct {
ID string
SecretHash []byte
UserID string
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
INSERT INTO user_session (id, secret_hash, user_id)
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 {
@ -37,13 +37,15 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (U
&i.ID,
&i.SecretHash,
&i.UserID,
&i.LastVerifiedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
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
WHERE id = $1
`
@ -55,7 +57,29 @@ func (q *Queries) SelectSessionById(ctx context.Context, id string) (UserSession
&i.ID,
&i.SecretHash,
&i.UserID,
&i.LastVerifiedAt,
&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
}

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
"go-backend/internal/auth"
"net/http"
)
@ -25,16 +26,22 @@ func (h *Handler) UserHandler(w http.ResponseWriter, r *http.Request) {
}
// Validate session
session, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
session, bump, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if bump {
http.SetCookie(w, auth.NewSessionCookie(cookie.Value, false))
}
// Get user info
user, err := h.users.FindByID(r.Context(), session.UserID)
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, ".")
if len(tokenParts) != 2 {
return nil, nil
return nil, false, nil
}
sessionID := tokenParts[0]
@ -71,28 +71,38 @@ func (s *AuthService) ValidateSessionToken(ctx context.Context, token string) (*
if err != nil {
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)
if err != nil {
return nil, err
return nil, false, err
}
return nil, nil
return nil, false, nil
}
tokenSecretHash := auth.HashSecret(sessionSecret)
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 {

View file

@ -15,7 +15,9 @@ CREATE TABLE IF NOT EXISTS user_session (
id TEXT PRIMARY KEY,
secret_hash BYTEA NOT NULL,
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);
@ -34,6 +36,11 @@ CREATE TRIGGER user_auth_set_updated_at
FOR EACH ROW
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
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