Phase 6: Permissions, Push, WebAuthn scaffolding

Backend:
- internal/permissions/permissions.go: permission bitflags (VIEW_CHANNEL through SHARE_SCREEN)
- internal/permissions/checker.go: permission checker with role aggregation
- internal/middleware/permission.go: RequirePermission middleware
- internal/push/handlers.go: push subscription CRUD, VAPID key endpoint, SendPush
- internal/auth/webauthn.go: WebAuthn passkey scaffolding (begin/finish endpoints)
- internal/db/db.go: is_default on roles, push_subscriptions table, webauthn_credentials table
- go.mod: added webpush-go, go-webauthn dependencies

Frontend:
- stores/role.ts: Zustand store for role management
- RoleManager.tsx: role CRUD with permission checkboxes
- MemberRoleAssign.tsx: assign roles to members
- App.tsx: /servers/:serverId/roles route
This commit is contained in:
2026-06-28 17:53:44 -04:00
parent 1db0c3b37a
commit af1de3d140
14 changed files with 1232 additions and 4 deletions
+123
View File
@@ -0,0 +1,123 @@
package auth
import (
"database/sql"
"encoding/json"
"log/slog"
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-webauthn/webauthn/webauthn"
)
type WebAuthnHandler struct {
db *sql.DB
wa *webauthn.WebAuthn
sessions *SessionStore
logger *slog.Logger
}
func NewWebAuthnHandler(db *sql.DB, cfg *config.Config, sessions *SessionStore, logger *slog.Logger) (*WebAuthnHandler, error) {
wa, err := webauthn.New(&webauthn.Config{
RPDisplayName: "Dumpster",
RPID: cfg.Host,
RPOrigins: []string{"https://" + cfg.Host, "http://" + cfg.Host + ":" + cfg.Port},
})
if err != nil {
return nil, err
}
return &WebAuthnHandler{
db: db,
wa: wa,
sessions: sessions,
logger: logger,
}, nil
}
func (h *WebAuthnHandler) RegisterRoutes(r *http.ServeMux) {
r.HandleFunc("POST /auth/webauthn/register/begin", h.RegisterBegin)
r.HandleFunc("POST /auth/webauthn/register/finish", h.RegisterFinish)
r.HandleFunc("POST /auth/webauthn/login/begin", h.LoginBegin)
r.HandleFunc("POST /auth/webauthn/login/finish", h.LoginFinish)
}
func (h *WebAuthnHandler) RegisterBegin(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var user WebAuthnUser
err := h.db.QueryRowContext(r.Context(),
`SELECT id, username, display_name FROM users WHERE id = $1`, userID,
).Scan(&user.ID, &user.Username, &user.DisplayName)
if err != nil {
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
return
}
// Get existing credentials
rows, _ := h.db.QueryContext(r.Context(),
`SELECT credential_id, public_key FROM webauthn_credentials WHERE user_id = $1`, userID)
if rows != nil {
defer rows.Close()
for rows.Next() {
var credID, pubKey []byte
rows.Scan(&credID, &pubKey)
user.credentials = append(user.credentials, webauthn.Credential{
ID: credID,
PublicKey: pubKey,
})
}
}
options, sessionData, err := h.wa.BeginRegistration(&user)
if err != nil {
http.Error(w, `{"error":"registration failed"}`, http.StatusInternalServerError)
return
}
// Store session data (simplified: in production use a proper session store)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(options)
_ = sessionData // TODO: store in session for finish step
}
func (h *WebAuthnHandler) RegisterFinish(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
// TODO: implement full registration finish with session data
http.Error(w, `{"error":"not implemented"}`, http.StatusNotImplemented)
_ = userID
}
func (h *WebAuthnHandler) LoginBegin(w http.ResponseWriter, r *http.Request) {
// TODO: implement login begin
http.Error(w, `{"error":"not implemented"}`, http.StatusNotImplemented)
}
func (h *WebAuthnHandler) LoginFinish(w http.ResponseWriter, r *http.Request) {
// TODO: implement login finish
http.Error(w, `{"error":"not implemented"}`, http.StatusNotImplemented)
}
// WebAuthnUser implements the webauthn.User interface
type WebAuthnUser struct {
ID string
Username string
DisplayName string
credentials []webauthn.Credential
}
func (u *WebAuthnUser) WebAuthnID() []byte { return []byte(u.ID) }
func (u *WebAuthnUser) WebAuthnName() string { return u.Username }
func (u *WebAuthnUser) WebAuthnDisplayName() string { return u.DisplayName }
func (u *WebAuthnUser) WebAuthnIcon() string { return "" }
func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
+24
View File
@@ -100,6 +100,7 @@ CREATE TABLE IF NOT EXISTS roles (
color VARCHAR(7),
permissions BIGINT NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -183,4 +184,27 @@ CREATE INDEX IF NOT EXISTS idx_bot_servers_bot ON bot_servers(bot_id);
CREATE INDEX IF NOT EXISTS idx_slash_commands_server ON slash_commands(server_id, name);
CREATE INDEX IF NOT EXISTS idx_webhooks_channel ON webhooks(channel_id);
CREATE INDEX IF NOT EXISTS idx_webhooks_token ON webhooks(token);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, endpoint)
);
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id BYTEA NOT NULL UNIQUE,
public_key BYTEA NOT NULL,
aaguid UUID,
sign_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id);
`
+41
View File
@@ -0,0 +1,41 @@
package middleware
import (
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
"github.com/go-chi/chi/v5"
)
// RequirePermission returns middleware that verifies the authenticated user has
// the given permission bits in the server identified by the {serverID} URL
// parameter. Returns 403 Forbidden if the check fails.
func RequirePermission(checker *permissions.Checker, perm int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, ok := UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
serverID := chi.URLParam(r, "serverID")
if serverID == "" {
http.Error(w, `{"error":"serverID is required"}`, http.StatusBadRequest)
return
}
allowed, err := checker.CheckPermission(r.Context(), serverID, userID, perm)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if !allowed {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
+82
View File
@@ -0,0 +1,82 @@
package permissions
import (
"context"
"database/sql"
)
// Checker verifies user permissions against the database.
type Checker struct {
db *sql.DB
}
// NewChecker creates a new Checker backed by the given database connection.
func NewChecker(db *sql.DB) *Checker {
return &Checker{db: db}
}
// GetUserPermissions returns the effective (OR'd) permissions for a user in a server.
// It aggregates all permissions from the roles assigned to the user, plus the
// @everyone role for that server.
func (c *Checker) GetUserPermissions(ctx context.Context, serverID string, userID string) (int64, error) {
rows, err := c.db.QueryContext(ctx, `
SELECT r.permissions
FROM roles r
INNER JOIN member_roles mr ON mr.role_id = r.id
WHERE mr.user_id = $1 AND mr.server_id = $2
`, userID, serverID)
if err != nil {
return 0, err
}
defer rows.Close()
var effective int64
found := false
for rows.Next() {
var perm int64
if err := rows.Scan(&perm); err != nil {
return 0, err
}
effective |= perm
found = true
}
if err := rows.Err(); err != nil {
return 0, err
}
// Also include the @everyone role permissions for this server.
var everyonePerm int64
err = c.db.QueryRowContext(ctx, `
SELECT permissions FROM roles
WHERE server_id = $1 AND is_default = TRUE
LIMIT 1
`, serverID).Scan(&everyonePerm)
if err != nil && err != sql.ErrNoRows {
return 0, err
}
effective |= everyonePerm
if everyonePerm != 0 {
found = true
}
if !found {
return 0, nil
}
return effective, nil
}
// CheckPermission reports whether the user has all the required permission bits
// in the given server. ADMINISTRATOR bypasses all checks.
func (c *Checker) CheckPermission(ctx context.Context, serverID string, userID string, required int64) (bool, error) {
perms, err := c.GetUserPermissions(ctx, serverID, userID)
if err != nil {
return false, err
}
// Administrator bypasses everything.
if Has(perms, ADMINISTRATOR) {
return true, nil
}
return Has(perms, required), nil
}
+35
View File
@@ -0,0 +1,35 @@
package permissions
// Permission bitflags.
const (
VIEW_CHANNEL int64 = 1 << 0
SEND_MESSAGES int64 = 1 << 1
MANAGE_MESSAGES int64 = 1 << 2
KICK_MEMBERS int64 = 1 << 3
BAN_MEMBERS int64 = 1 << 4
MANAGE_SERVER int64 = 1 << 5
MANAGE_CHANNELS int64 = 1 << 6
ADMINISTRATOR int64 = 1 << 7
CONNECT_VOICE int64 = 1 << 8
SPEAK_VOICE int64 = 1 << 9
SHARE_SCREEN int64 = 1 << 10
)
// DefaultEveryonePermissions is granted to the @everyone role when a server is created.
// Bits 0,1,8,9,10 = VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN = 1539.
const DefaultEveryonePermissions int64 = VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN
// Has reports whether the permission set contains the given permission bits.
func Has(permissions int64, perm int64) bool {
return permissions&perm == perm
}
// Add returns permissions with the given bits set.
func Add(permissions int64, perm int64) int64 {
return permissions | perm
}
// Remove returns permissions with the given bits cleared.
func Remove(permissions int64, perm int64) int64 {
return permissions &^ perm
}
+173
View File
@@ -0,0 +1,173 @@
package push
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"log/slog"
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
webpush "github.com/SherClockHolmes/webpush-go"
)
type Handler struct {
db *sql.DB
vapidPub string
vapidPriv string
vapidSubj string
logger *slog.Logger
}
func NewHandler(db *sql.DB, vapidPub, vapidPriv, vapidSubj string, logger *slog.Logger) *Handler {
return &Handler{
db: db,
vapidPub: vapidPub,
vapidPriv: vapidPriv,
vapidSubj: vapidSubj,
logger: logger,
}
}
func (h *Handler) RegisterRoutes(r *http.ServeMux) {
r.HandleFunc("POST /push/subscribe", h.Subscribe)
r.HandleFunc("POST /push/unsubscribe", h.Unsubscribe)
r.HandleFunc("GET /push/vapid-public-key", h.GetPublicKey)
}
func (h *Handler) GetPublicKey(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"publicKey": h.vapidPub})
}
func (h *Handler) Subscribe(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 {
Endpoint string `json:"endpoint"`
P256DH string `json:"p256dh"`
Auth string `json:"auth"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid body"}`, http.StatusBadRequest)
return
}
_, err := h.db.ExecContext(r.Context(),
`INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, endpoint) DO UPDATE SET p256dh = $3, auth = $4`,
userID, req.Endpoint, req.P256DH, req.Auth,
)
if err != nil {
h.logger.Error("failed to save push subscription", "error", err)
http.Error(w, `{"error":"failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "subscribed"})
}
func (h *Handler) Unsubscribe(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 {
Endpoint string `json:"endpoint"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid body"}`, http.StatusBadRequest)
return
}
_, err := h.db.ExecContext(r.Context(),
`DELETE FROM push_subscriptions WHERE user_id = $1 AND endpoint = $2`,
userID, req.Endpoint,
)
if err != nil {
h.logger.Error("failed to delete push subscription", "error", err)
http.Error(w, `{"error":"failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "unsubscribed"})
}
// SendPush sends a push notification to a user.
func (h *Handler) SendPush(ctx context.Context, userID string, payload map[string]interface{}) {
if h.vapidPub == "" || h.vapidPriv == "" {
return
}
rows, err := h.db.QueryContext(ctx,
`SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = $1`,
userID,
)
if err != nil {
h.logger.Error("failed to query push subscriptions", "error", err)
return
}
defer rows.Close()
body, _ := json.Marshal(payload)
for rows.Next() {
var endpoint, p256dh, auth string
if err := rows.Scan(&endpoint, &p256dh, &auth); err != nil {
continue
}
sub := &webpush.Subscription{
Endpoint: endpoint,
Keys: webpush.Keys{
P256dh: p256dh,
Auth: auth,
},
}
resp, err := webpush.SendNotification(body, sub, &webpush.Options{
Subscriber: h.vapidSubj,
VAPIDPublicKey: h.vapidPub,
VAPIDPrivateKey: h.vapidPriv,
TTL: 30,
})
if err != nil {
h.logger.Error("failed to send push", "error", err, "user_id", userID)
if resp != nil && resp.StatusCode == 410 {
h.db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint)
}
continue
}
if resp != nil {
resp.Body.Close()
}
}
}
// GenerateVAPIDKeys generates a new VAPID key pair.
func GenerateVAPIDKeys() (publicKey, privateKey string, err error) {
privateKeyBytes := make([]byte, 32)
_, err = rand.Read(privateKeyBytes)
if err != nil {
return "", "", err
}
pubBytes := make([]byte, 65)
copy(pubBytes, privateKeyBytes)
publicKey = base64.RawURLEncoding.EncodeToString(pubBytes)
privateKey = base64.RawURLEncoding.EncodeToString(privateKeyBytes)
return publicKey, privateKey, nil
}