48a99d58ee
DUMPSTER_PORT=8080 (internal) was baked into email reset links, producing http://dumpster.dustin.coffee:8080/reset-password which doesn't resolve through Caddy. Added Config.AppURL() that reads APP_URL env var (set to https://dumpster.dustin.coffee on server), falls back to http://host:port for dev.
217 lines
6.3 KiB
Go
217 lines
6.3 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
|
)
|
|
|
|
func generateToken() string {
|
|
b := make([]byte, 32)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func (h *Handler) RequestVerification(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":"user not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
token := generateToken()
|
|
_, err = h.db.ExecContext(r.Context(), `
|
|
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
|
VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours')
|
|
`, userID, token)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
appURL := h.cfg.AppURL()
|
|
|
|
if err := h.mailer.SendVerificationEmail(user.Email, user.Username, token, appURL); err != nil {
|
|
http.Error(w, `{"error":"failed to send email"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var userID string
|
|
err := h.db.QueryRowContext(r.Context(), `
|
|
DELETE FROM user_tokens
|
|
WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW()
|
|
RETURNING user_id
|
|
`, req.Token).Scan(&userID)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
_, err = h.db.ExecContext(r.Context(), `
|
|
UPDATE users SET email_verified = TRUE WHERE id = $1
|
|
`, userID)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to update user"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Email string `json:"email"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var userID, username string
|
|
err := h.db.QueryRowContext(r.Context(), `
|
|
SELECT id, username FROM users WHERE email = $1
|
|
`, req.Email).Scan(&userID, &username)
|
|
if err != nil {
|
|
// Silent fail to prevent email enumeration
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
token := generateToken()
|
|
_, err = h.db.ExecContext(r.Context(), `
|
|
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
|
VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour')
|
|
`, userID, token)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
appURL := h.cfg.AppURL()
|
|
|
|
h.mailer.SendPasswordResetEmail(req.Email, username, token, appURL)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Token string `json:"token"`
|
|
NewPassword string `json:"new_password"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(req.NewPassword) < 8 {
|
|
http.Error(w, `{"error":"password too short"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var userID string
|
|
err := h.db.QueryRowContext(r.Context(), `
|
|
DELETE FROM user_tokens
|
|
WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW()
|
|
RETURNING user_id
|
|
`, req.Token).Scan(&userID)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
hash, err := HashPassword(req.NewPassword)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to hash password"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
_, err = h.db.ExecContext(r.Context(), `
|
|
UPDATE users SET password_hash = $2 WHERE id = $1
|
|
`, userID, hash)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to update user"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Delete all sessions to force re-login
|
|
h.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE user_id = $1`, userID)
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) AdminAnnounce(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Subject string `json:"subject"`
|
|
Body string `json:"body"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if user is an admin across the whole system.
|
|
// For now, let's assume if they have ADMINISTRATOR on ANY server, they can announce.
|
|
// Or maybe there is a global admin flag? Let's check permissions.
|
|
// Actually, let's just make sure they have MANAGE_SERVER on a specific server or something.
|
|
// DumpsterChat doesn't have system-level admins out of the box, only Server-level.
|
|
// Let's require them to be the owner of at least one server, or have ADMINISTRATOR on a server?
|
|
// The prompt just said "admin sent announcements". Let's allow it for users who are in any server with ADMINISTRATOR.
|
|
var isAdmin bool
|
|
err := h.db.QueryRowContext(r.Context(), `
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM member_roles mr
|
|
JOIN roles r ON mr.role_id = r.id
|
|
WHERE mr.user_id = $1 AND (r.permissions & $2) = $2
|
|
)
|
|
`, userID, permissions.ADMINISTRATOR).Scan(&isAdmin)
|
|
|
|
if err != nil || !isAdmin {
|
|
http.Error(w, `{"error":"must be an administrator"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
rows, err := h.db.QueryContext(r.Context(), `SELECT email FROM users`)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to fetch users"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var email string
|
|
if err := rows.Scan(&email); err == nil && email != "" {
|
|
// This is synchronous and could block, ideally use a queue, but this is fine for now
|
|
h.mailer.SendAnnouncement(email, req.Subject, req.Body)
|
|
}
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|