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:
@@ -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 }
|
||||
@@ -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);
|
||||
`
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user