package api import ( "encoding/json" "errors" "net/http" "time" "github.com/jackc/pgx/v5" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // sessionCookieMaxAge is the cookie lifetime. Sessions don't auto-expire // server-side yet (future work); the cookie still caps browser-side lifetime // so an abandoned laptop doesn't stay logged in forever. const sessionCookieMaxAge = 30 * 24 * time.Hour func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) { var req LoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") return } if req.Username == "" || req.Password == "" { writeErr(w, http.StatusBadRequest, "bad_request", "username and password required") return } q := dbq.New(h.pool) user, err := q.GetUserByUsername(r.Context(), req.Username) if err != nil { if errors.Is(err, pgx.ErrNoRows) { writeErr(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password") return } h.logger.Error("api: user lookup failed", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") return } if !auth.VerifyPassword(user.PasswordHash, req.Password) { writeErr(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password") return } token, err := auth.MintSessionToken() if err != nil { h.logger.Error("api: mint session token failed", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "mint failed") return } if _, err := q.InsertSession(r.Context(), dbq.InsertSessionParams{ UserID: user.ID, TokenHash: auth.HashSessionToken(token), UserAgent: r.UserAgent(), }); err != nil { h.logger.Error("api: insert session failed", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "insert failed") return } http.SetCookie(w, &http.Cookie{ Name: auth.SessionCookieName, Value: token, Path: "/", HttpOnly: true, Secure: r.TLS != nil, // dev over http stays functional; prod over https gets Secure SameSite: http.SameSiteStrictMode, MaxAge: int(sessionCookieMaxAge.Seconds()), }) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(LoginResponse{ Token: token, User: UserView{ ID: user.ID, Username: user.Username, IsAdmin: user.IsAdmin, }, }) }