Files
dumpsterChat/internal/auth/handlers.go
T

393 lines
11 KiB
Go

package auth
import (
"context"
"crypto/md5"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5"
)
type Handler struct {
db *sql.DB
cfg *config.Config
sessions *SessionStore
hub *gateway.Hub
}
func NewHandler(db *sql.DB, cfg *config.Config, hub *gateway.Hub) *Handler {
return &Handler{
db: db,
cfg: cfg,
sessions: NewSessionStore(db, cfg),
hub: hub,
}
}
func (h *Handler) RegisterPublicRoutes(r chi.Router) {
r.Post("/register", h.Register)
r.Post("/login/password", h.Login)
r.Post("/logout", h.Logout)
}
func (h *Handler) RegisterProtectedRoutes(r chi.Router) {
r.Get("/auth/me", h.Me)
r.Patch("/auth/me", h.UpdateProfile)
r.Put("/auth/me/password", h.ChangePassword)
}
type registerRequest struct {
Email string `json:"email"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Password string `json:"password"`
}
type loginRequest struct {
Email string `json:"email"`
Username string `json:"username"`
Password string `json:"password"`
}
type updateProfileRequest struct {
DisplayName string `json:"display_name"`
Bio string `json:"bio"`
AccentColor string `json:"accent_color"`
StatusText string `json:"status_text"`
Status string `json:"status"`
}
type changePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
type UserProfile struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Avatar string `json:"avatar"`
Bio string `json:"bio"`
AccentColor string `json:"accent_color"`
Status string `json:"status"`
StatusText string `json:"status_text"`
CreatedAt string `json:"created_at"`
}
func gravatarURL(email string) string {
email = strings.ToLower(strings.TrimSpace(email))
hash := md5.Sum([]byte(email))
return fmt.Sprintf("https://www.gravatar.com/avatar/%s?d=identicon&s=256", hex.EncodeToString(hash[:]))
}
// @Summary Register a new user
// @Description Create a new user account
// @Tags auth
// @Accept json
// @Produce json
// @Param body body registerRequest true "Registration data"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 409 {object} map[string]string
// @Router /auth/register [post]
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Email == "" || req.Username == "" || req.Password == "" {
http.Error(w, `{"error":"missing fields"}`, http.StatusBadRequest)
return
}
hash, err := HashPassword(req.Password)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
displayName := req.DisplayName
if displayName == "" {
displayName = req.Username
}
var userID string
err = h.db.QueryRowContext(r.Context(), `
INSERT INTO users (username, display_name, email, password_hash, avatar)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, req.Username, displayName, req.Email, hash, gravatarURL(req.Email)).Scan(&userID)
if err != nil {
http.Error(w, `{"error":"user exists"}`, http.StatusConflict)
return
}
token, err := h.sessions.Create(r.Context(), userID)
if err != nil {
http.Error(w, `{"error":"session error"}`, http.StatusInternalServerError)
return
}
h.setSessionCookie(w, token)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"id": userID})
}
// @Summary Login with email and password
// @Description Authenticate with email and password
// @Tags auth
// @Accept json
// @Produce json
// @Param body body loginRequest true "Login credentials"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Router /auth/login/password [post]
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
login := strings.TrimSpace(req.Email)
if login == "" {
login = strings.TrimSpace(req.Username)
}
if login == "" || req.Password == "" {
http.Error(w, `{"error":"email/username and password required"}`, http.StatusBadRequest)
return
}
var userID, hash string
err := h.db.QueryRowContext(r.Context(), `
SELECT id, password_hash FROM users WHERE LOWER(email) = LOWER($1) OR LOWER(username) = LOWER($1)
`, login).Scan(&userID, &hash)
if err != nil {
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
ok, err := VerifyPassword(req.Password, hash)
if err != nil || !ok {
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
token, err := h.sessions.Create(r.Context(), userID)
if err != nil {
http.Error(w, `{"error":"session error"}`, http.StatusInternalServerError)
return
}
h.setSessionCookie(w, token)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"id": userID})
}
// @Summary Logout
// @Description End the current session
// @Tags auth
// @Success 204
// @Router /auth/logout [post]
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(h.cfg.Session.CookieName)
if err == nil {
h.sessions.Delete(r.Context(), cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: h.cfg.Session.CookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
w.WriteHeader(http.StatusNoContent)
}
// @Summary Get current user profile
// @Description Get the authenticated user's profile
// @Tags auth
// @Produce json
// @Security SessionAuth
// @Success 200 {object} UserProfile
// @Failure 401 {object} map[string]string
// @Router /auth/me [get]
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
user, err := h.getProfile(r.Context(), userID)
if err != nil {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
// @Summary Update current user profile
// @Description Update the authenticated user's profile
// @Tags auth
// @Accept json
// @Produce json
// @Security SessionAuth
// @Param body body updateProfileRequest true "Profile update data"
// @Success 200 {object} UserProfile
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Router /auth/me [patch]
func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var req updateProfileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if len(req.Bio) > 250 {
http.Error(w, `{"error":"bio too long"}`, http.StatusBadRequest)
return
}
if len(req.StatusText) > 128 {
http.Error(w, `{"error":"status text too long"}`, http.StatusBadRequest)
return
}
if len(req.DisplayName) > 32 {
http.Error(w, `{"error":"display name too long"}`, http.StatusBadRequest)
return
}
if req.AccentColor != "" && len(req.AccentColor) != 7 {
http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest)
return
}
if req.Status != "" && !isValidStatus(req.Status) {
http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest)
return
}
_, err := h.db.ExecContext(r.Context(), `
UPDATE users
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = COALESCE(NULLIF($6, ''), status)
WHERE id = $1
`, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText, req.Status)
if err != nil {
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
return
}
if req.Status != "" && h.hub != nil {
user, err := h.getProfile(r.Context(), userID)
if err == nil {
h.hub.SetUserStatus(userID, req.Status)
h.hub.BroadcastPresence(userID, user.Username, req.Status)
}
}
user, err := h.getProfile(r.Context(), userID)
if err != nil {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
func isValidStatus(status string) bool {
switch status {
case "online", "idle", "dnd", "offline":
return true
}
return false
}
func (h *Handler) getProfile(ctx context.Context, userID string) (UserProfile, error) {
var user UserProfile
return user, h.db.QueryRowContext(ctx, `
SELECT id, username, display_name, email, avatar, bio, accent_color, status, status_text, created_at
FROM users WHERE id = $1
`, userID).Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.Avatar, &user.Bio, &user.AccentColor, &user.Status, &user.StatusText, &user.CreatedAt)
}
func (h *Handler) setSessionCookie(w http.ResponseWriter, token string) {
SetSessionCookie(w, h.cfg.Session.CookieName, token, h.cfg.Session.Duration)
}
// ChangePassword handles PUT /auth/me/password.
func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var req changePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.CurrentPassword == "" || req.NewPassword == "" {
http.Error(w, `{"error":"current and new password required"}`, http.StatusBadRequest)
return
}
if len(req.NewPassword) < 8 {
http.Error(w, `{"error":"password must be at least 8 characters"}`, http.StatusBadRequest)
return
}
// Verify current password
var currentHash string
err := h.db.QueryRowContext(r.Context(), "SELECT password_hash FROM users WHERE id = $1", userID).Scan(&currentHash)
if err != nil {
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
return
}
ok, err = VerifyPassword(req.CurrentPassword, currentHash)
if err != nil || !ok {
http.Error(w, `{"error":"current password is incorrect"}`, http.StatusUnauthorized)
return
}
// Hash new password
newHash, err := HashPassword(req.NewPassword)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
_, err = h.db.ExecContext(r.Context(), "UPDATE users SET password_hash = $1 WHERE id = $2", newHash, userID)
if err != nil {
http.Error(w, `{"error":"failed to update password"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "password updated"})
}