Phase 2: auth, session management, layout, PWA manifest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const ctxUser contextKey = "user"
|
||||
|
||||
// User is the authenticated user stored in request context.
|
||||
type User struct {
|
||||
ID int64
|
||||
Name string
|
||||
}
|
||||
|
||||
// SQLiteStore implements scs.Store using a modernc.org/sqlite connection.
|
||||
type SQLiteStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
|
||||
|
||||
func (s *SQLiteStore) Delete(token string) error {
|
||||
_, err := s.db.Exec("DELETE FROM sessions WHERE token = ?", token)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) Find(token string) ([]byte, bool, error) {
|
||||
var data []byte
|
||||
err := s.db.QueryRow(
|
||||
"SELECT data FROM sessions WHERE token = ? AND expiry > ?",
|
||||
token, time.Now().Unix(),
|
||||
).Scan(&data)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) Commit(token string, b []byte, expiry time.Time) error {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT OR REPLACE INTO sessions (token, data, expiry) VALUES (?, ?, ?)",
|
||||
token, b, expiry.Unix(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Manager wraps scs.SessionManager with application-level helpers.
|
||||
type Manager struct {
|
||||
SM *scs.SessionManager
|
||||
}
|
||||
|
||||
func NewManager(db *sql.DB) *Manager {
|
||||
sm := scs.New()
|
||||
sm.Store = NewStore(db)
|
||||
sm.Lifetime = 30 * 24 * time.Hour
|
||||
sm.Cookie.HttpOnly = true
|
||||
sm.Cookie.SameSite = http.SameSiteLaxMode
|
||||
return &Manager{SM: sm}
|
||||
}
|
||||
|
||||
// SetUser persists the authenticated user in the session.
|
||||
func (m *Manager) SetUser(r *http.Request, id int64, name string) {
|
||||
m.SM.Put(r.Context(), "userID", id)
|
||||
m.SM.Put(r.Context(), "userName", name)
|
||||
}
|
||||
|
||||
// ClearUser destroys the session (used on logout).
|
||||
func (m *Manager) ClearUser(r *http.Request) error {
|
||||
return m.SM.Destroy(r.Context())
|
||||
}
|
||||
|
||||
// UserFromCtx returns the authenticated user from context, or nil.
|
||||
func UserFromCtx(ctx context.Context) *User {
|
||||
u, _ := ctx.Value(ctxUser).(*User)
|
||||
return u
|
||||
}
|
||||
|
||||
// RequireAuth redirects unauthenticated requests to /login.
|
||||
func (m *Manager) RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := m.SM.GetInt64(r.Context(), "userID")
|
||||
if id == 0 {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
name := m.SM.GetString(r.Context(), "userName")
|
||||
ctx := context.WithValue(r.Context(), ctxUser, &User{ID: id, Name: name})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// CSRFToken returns the session's CSRF token, creating one if needed.
|
||||
func (m *Manager) CSRFToken(r *http.Request) string {
|
||||
tok := m.SM.GetString(r.Context(), "csrf")
|
||||
if tok != "" {
|
||||
return tok
|
||||
}
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
tok = hex.EncodeToString(b)
|
||||
m.SM.Put(r.Context(), "csrf", tok)
|
||||
return tok
|
||||
}
|
||||
|
||||
// CheckCSRF returns true if the form's csrf_token matches the session token.
|
||||
func (m *Manager) CheckCSRF(r *http.Request) bool {
|
||||
session := m.SM.GetString(r.Context(), "csrf")
|
||||
form := r.FormValue("csrf_token")
|
||||
return session != "" && subtle.ConstantTimeCompare([]byte(session), []byte(form)) == 1
|
||||
}
|
||||
|
||||
// SetFlash stores a one-time flash message in the session.
|
||||
func (m *Manager) SetFlash(r *http.Request, msg string) {
|
||||
m.SM.Put(r.Context(), "flash", msg)
|
||||
}
|
||||
|
||||
// PopFlash reads and removes the flash message.
|
||||
func (m *Manager) PopFlash(r *http.Request) string {
|
||||
return m.SM.PopString(r.Context(), "flash")
|
||||
}
|
||||
@@ -47,6 +47,13 @@ CREATE TABLE IF NOT EXISTS user_question_stats (
|
||||
PRIMARY KEY (user_id, question_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
data BLOB NOT NULL,
|
||||
expiry INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(expiry);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_test_answers_test ON test_answers(test_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_answers_question ON answers(question_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_stats_user ON user_question_stats(user_id);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"qbank/internal/auth"
|
||||
"qbank/internal/db"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
auth *auth.Manager
|
||||
repo *db.Repo
|
||||
render *Renderer
|
||||
}
|
||||
|
||||
func NewAuthHandler(a *auth.Manager, repo *db.Repo, r *Renderer) *AuthHandler {
|
||||
return &AuthHandler{auth: a, repo: repo, render: r}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) LoginGet(w http.ResponseWriter, r *http.Request) {
|
||||
h.render.Render(w, http.StatusOK, "login", map[string]any{
|
||||
"CSRFToken": h.auth.CSRFToken(r),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) LoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
|
||||
if !h.auth.CheckCSRF(r) {
|
||||
HTTPError(w, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
|
||||
user, err := h.repo.GetUserByName(username)
|
||||
if err != nil || bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
|
||||
slog.Info("login failed", "username", username)
|
||||
h.render.Render(w, http.StatusUnauthorized, "login", map[string]any{
|
||||
"CSRFToken": h.auth.CSRFToken(r),
|
||||
"Error": "Invalid username or password.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.auth.SetUser(r, user.ID, user.Name)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.auth.CheckCSRF(r) {
|
||||
HTTPError(w, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := h.auth.ClearUser(r); err != nil {
|
||||
slog.Error("logout", "err", err)
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"qbank/internal/auth"
|
||||
)
|
||||
|
||||
type HomeHandler struct {
|
||||
auth *auth.Manager
|
||||
render *Renderer
|
||||
}
|
||||
|
||||
func NewHomeHandler(a *auth.Manager, r *Renderer) *HomeHandler {
|
||||
return &HomeHandler{auth: a, render: r}
|
||||
}
|
||||
|
||||
func (h *HomeHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
h.render.Render(w, http.StatusOK, "home", BaseData(h.auth, r))
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"qbank/internal/auth"
|
||||
)
|
||||
|
||||
// Renderer parses and executes HTML templates from a directory.
|
||||
type Renderer struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
func NewRenderer(dir string) *Renderer { return &Renderer{dir: dir} }
|
||||
|
||||
// Render executes layout.html + <name>.html, passing data to the "layout" template.
|
||||
func (r *Renderer) Render(w http.ResponseWriter, status int, name string, data any) {
|
||||
t, err := template.ParseFiles(
|
||||
filepath.Join(r.dir, "layout.html"),
|
||||
filepath.Join(r.dir, name+".html"),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("parse template", "name", name, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
slog.Error("execute template", "name", name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPError writes a plain-text HTTP error.
|
||||
func HTTPError(w http.ResponseWriter, status int) {
|
||||
http.Error(w, http.StatusText(status), status)
|
||||
}
|
||||
|
||||
// BaseData builds the common template map (User, CSRFToken, Flash).
|
||||
func BaseData(a *auth.Manager, r *http.Request) map[string]any {
|
||||
return map[string]any{
|
||||
"User": auth.UserFromCtx(r.Context()),
|
||||
"CSRFToken": a.CSRFToken(r),
|
||||
"Flash": a.PopFlash(r),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user