diff --git a/README.md b/README.md index 5bfa6cc..ac8abc6 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -69,4 +74,4 @@ Query codegen is configured in `sqlc.yaml` (engine `postgresql`, schema read fro ## Database Schema - `user_auth` - User accounts with Argon2 password hashes -- `user_session` - User sessions with SHA-256 hashed token secrets \ No newline at end of file +- `user_session` - User sessions with SHA-256 hashed token secrets diff --git a/cmd/main.go b/cmd/main.go index 99c539b..45f507b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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) diff --git a/internal/auth/session.go b/internal/auth/session.go index e043b83..a75d531 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -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 + +} diff --git a/internal/database/queries/sessions.sql b/internal/database/queries/sessions.sql index 3d483c6..0b5f948 100644 --- a/internal/database/queries/sessions.sql +++ b/internal/database/queries/sessions.sql @@ -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; \ No newline at end of file +DELETE FROM user_session WHERE id = $1; diff --git a/internal/database/sqlc/models.go b/internal/database/sqlc/models.go index f455c3c..e98226c 100644 --- a/internal/database/sqlc/models.go +++ b/internal/database/sqlc/models.go @@ -18,8 +18,10 @@ type UserAuth struct { } type UserSession struct { - ID string - SecretHash []byte - UserID string - CreatedAt time.Time + ID string + SecretHash []byte + UserID string + LastVerifiedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time } diff --git a/internal/database/sqlc/sessions.sql.go b/internal/database/sqlc/sessions.sql.go index 61dcb59..49f14c8 100644 --- a/internal/database/sqlc/sessions.sql.go +++ b/internal/database/sqlc/sessions.sql.go @@ -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 } diff --git a/internal/handlers/handler.go b/internal/handlers/handler.go index 7b43246..295f9db 100644 --- a/internal/handlers/handler.go +++ b/internal/handlers/handler.go @@ -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 diff --git a/internal/handlers/login.go b/internal/handlers/login.go index f3ac545..890a9c6 100644 --- a/internal/handlers/login.go +++ b/internal/handlers/login.go @@ -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) diff --git a/internal/handlers/signup.go b/internal/handlers/signup.go index feb0919..f54d8e6 100644 --- a/internal/handlers/signup.go +++ b/internal/handlers/signup.go @@ -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", "/") diff --git a/internal/handlers/user.go b/internal/handlers/user.go index f31d65c..69f5c78 100644 --- a/internal/handlers/user.go +++ b/internal/handlers/user.go @@ -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 { diff --git a/internal/services/auth.go b/internal/services/auth.go index 150483e..c5d54d9 100644 --- a/internal/services/auth.go +++ b/internal/services/auth.go @@ -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 { diff --git a/migrations/00001_init.sql b/migrations/00001_init.sql index 19dfb3f..d2b0e7d 100644 --- a/migrations/00001_init.sql +++ b/migrations/00001_init.sql @@ -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; diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..dabcbb7 --- /dev/null +++ b/todo.txt @@ -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