Anyone authenticated can still read the shared platform list, but only an
admin may add, rename or delete from it.
- users.is_admin boolean, defaulting to false. The very first account ever
registered becomes an admin automatically (CreateUser's INSERT uses
NOT EXISTS (SELECT 1 FROM users) for is_admin), so there's always at
least one without manual setup; promoting anyone beyond that is a manual
UPDATE for now (documented in the migration's comment on the column).
- GetSessionAndUser, GetUserByEmail and GetUserByID now also select
is_admin, and it flows through to model.User (and so to
/api/auth/{register,login,me}'s response) and to the auth middleware's
authedUser.
- New requireAdminMiddleware + addAdminAuth, layered on top of the existing
auth middleware. Wired onto create/update/delete-platform; list/get stay
on plain addAuth, since reading the list only requires being logged in.
Verified end to end: the first registered user comes back is_admin:true,
the second false; the non-admin gets 403 on create/update/delete-platform
but 200 on list; the admin can do all of it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
113 lines
3.9 KiB
Go
113 lines
3.9 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"completed/internal/auth"
|
|
"completed/internal/db"
|
|
)
|
|
|
|
// sessionCookieName is also used by the auth routes when setting/clearing
|
|
// the cookie, so it lives here as the one shared constant.
|
|
const sessionCookieName = "session"
|
|
|
|
// bearerToken extracts the token from "Authorization: Bearer <token>", or
|
|
// the empty string if the header is missing or malformed.
|
|
func bearerToken(header string) string {
|
|
const prefix = "Bearer "
|
|
if !strings.HasPrefix(header, prefix) {
|
|
return ""
|
|
}
|
|
return strings.TrimPrefix(header, prefix)
|
|
}
|
|
|
|
// tokenFromRequest looks for a session token in the Authorization header
|
|
// first (used by the TUI and Fyne clients), falling back to the session
|
|
// cookie (used by the web client).
|
|
func tokenFromRequest(ctx huma.Context) string {
|
|
if t := bearerToken(ctx.Header("Authorization")); t != "" {
|
|
return t
|
|
}
|
|
if c, err := huma.ReadCookie(ctx, sessionCookieName); err == nil && c != nil {
|
|
return c.Value
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// newAuthMiddleware returns Huma middleware that requires a valid session,
|
|
// set via AuthMiddleware on individual operations. On success it stores the
|
|
// authenticated user in the request context for userFromContext to read.
|
|
func newAuthMiddleware(api huma.API, queries db.Querier) func(huma.Context, func(huma.Context)) {
|
|
return func(ctx huma.Context, next func(huma.Context)) {
|
|
token := tokenFromRequest(ctx)
|
|
if token == "" {
|
|
huma.WriteErr(api, ctx, 401, "authentication required")
|
|
return
|
|
}
|
|
|
|
row, err := queries.GetSessionAndUser(ctx.Context(), auth.HashSessionToken(token))
|
|
if err != nil {
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
// Fail closed: an unreachable DB fails auth, not through.
|
|
huma.WriteErr(api, ctx, 401, "authentication required")
|
|
return
|
|
}
|
|
huma.WriteErr(api, ctx, 401, "session expired or invalid")
|
|
return
|
|
}
|
|
|
|
// Best-effort; a failure to record activity shouldn't fail the request.
|
|
_ = queries.TouchSession(ctx.Context(), auth.HashSessionToken(token))
|
|
|
|
user := authedUser{ID: row.ID, Email: row.Email, Name: row.Name, Bio: row.Bio.String, IsAdmin: row.IsAdmin}
|
|
newCtx := huma.WithValue(ctx, userCtxKey, user)
|
|
newCtx = huma.WithValue(newCtx, tokenCtxKey, token)
|
|
next(newCtx)
|
|
}
|
|
}
|
|
|
|
// addAuth attaches the auth middleware to op, so it requires a valid
|
|
// session. Call it on the *huma.Operation value before huma.Register.
|
|
func addAuth(op *huma.Operation, api huma.API, queries db.Querier) {
|
|
op.Middlewares = append(op.Middlewares, newAuthMiddleware(api, queries))
|
|
}
|
|
|
|
// requireAdminMiddleware runs after newAuthMiddleware (see addAdminAuth) and
|
|
// rejects the request unless the authenticated user is an admin. It reads
|
|
// the context value that middleware set directly, rather than calling
|
|
// userFromContext, so a wiring mistake (this middleware attached without
|
|
// the auth one ahead of it) fails the request with 403 instead of panicking.
|
|
func requireAdminMiddleware(api huma.API) func(huma.Context, func(huma.Context)) {
|
|
return func(ctx huma.Context, next func(huma.Context)) {
|
|
u, ok := ctx.Context().Value(userCtxKey).(authedUser)
|
|
if !ok || !u.IsAdmin {
|
|
huma.WriteErr(api, ctx, 403, "admin access required")
|
|
return
|
|
}
|
|
next(ctx)
|
|
}
|
|
}
|
|
|
|
// addAdminAuth attaches both the auth and the admin-only middleware to op,
|
|
// for routes that mutate data every user can see (the shared platform
|
|
// list) but only an admin should be able to change.
|
|
func addAdminAuth(op *huma.Operation, api huma.API, queries db.Querier) {
|
|
addAuth(op, api, queries)
|
|
op.Middlewares = append(op.Middlewares, requireAdminMiddleware(api))
|
|
}
|
|
|
|
// tokenCtxKey holds the raw bearer/cookie token for the current request, so
|
|
// the logout handler can hash and delete the exact session that was used
|
|
// without re-parsing the request.
|
|
const tokenCtxKey ctxKey = "completed.token"
|
|
|
|
func tokenFromContext(ctx context.Context) string {
|
|
t, _ := ctx.Value(tokenCtxKey).(string)
|
|
return t
|
|
}
|