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:
parent
1d70b3a6d3
commit
43219268e9
13 changed files with 140 additions and 58 deletions
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -21,5 +21,7 @@ type UserSession struct {
|
|||
ID string
|
||||
SecretHash []byte
|
||||
UserID string
|
||||
LastVerifiedAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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", "/")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
7
todo.txt
Normal 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
|
||||
Loading…
Reference in a new issue