Tredje trin mod iOS-appen. I en browser deler fladen og API'et origin: hver sti er relativ, og sessionen er en httpOnly-cookie. I appen ligger fladen i appen selv på capacitor://localhost, og serveren er den, ejeren skrev ind. Alt, der er forskelligt, bor i den nye `platform.js`, så browserens svar er præcis, hvad det var før. - `apiURL()` og `authorize()`: relative stier og cookie i en browser; den valgte server og `Authorization: Bearer` i appen, `credentials: 'omit'`. - Login sender `client: app` og gemmer `session_token`; log ud fjerner det fra telefonen, også når serveren ikke svarede. - Live-synk i appen beder om en billet og åbner socket'en på serveren med den. - Første skærm i appen: "Hvilken Verdande vil du logge ind på?". Adressen prøves med /ping, før et adgangskodefelt vises, så en tastefejl, en router eller en server, der er nede, får sin egen sætning. Passkeys og opsætning af første konto vises ikke i appen; begge hører til serverens eget domæne. - Billeder og downloads under /api/v1 hentes med tokenet og vises fra en blob, i appen og kun dér (`nativemedia.js`). Stien bevares i `data-src`, og `richtext.js` læser den først, så en note aldrig gemmer en blob-adresse. - Service worker og Web Push er slået fra i appen: WKWebView har ingen af dem. Serveren: `VERDANDE_APP_ORIGINS` regner flere origins som appens. Den findes for E2E-projektet `app-origin`, der kører fladen fra app.localhost — samme server, anden origin — med en Capacitor-stub og uden cookie. Prøven vælger server, logger ind, ser et projekt fra "en anden enhed" dukke op over socket'en, viser et billede fra en blob og logger ud. Fire plantede fejl, hver fanget af prøven ved sit eget trin: ingen bearer, socket uden billet, billeder ikke hentet, token efterladt ved log ud. Dokumenterer også `VERDANDE_APP_SESSION_TTL` fra trin 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
304 lines
10 KiB
Go
304 lines
10 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/kristianwind/verdande/internal/auth"
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
)
|
|
|
|
type ctxKey int
|
|
|
|
const (
|
|
ctxUser ctxKey = iota
|
|
ctxSession
|
|
// ctxTicket marks a socket opened with a one-use ticket rather than a cookie.
|
|
ctxTicket
|
|
)
|
|
|
|
// sessionCookie is prefixed with __Host- when served over HTTPS. That prefix is
|
|
// enforced by the browser: it refuses the cookie unless it is Secure, path=/ and
|
|
// has no Domain — which means a subdomain cannot set a cookie that overwrites this
|
|
// one. Over plain HTTP the prefix is invalid, so a local dev instance uses the
|
|
// bare name.
|
|
const (
|
|
sessionCookieSecure = "__Host-verdande_session"
|
|
sessionCookieInsecure = "verdande_session"
|
|
)
|
|
|
|
func (s *Server) cookieName() string {
|
|
if s.secureCookies() {
|
|
return sessionCookieSecure
|
|
}
|
|
return sessionCookieInsecure
|
|
}
|
|
|
|
func (s *Server) secureCookies() bool {
|
|
return strings.HasPrefix(s.cfg.BaseURL, "https://")
|
|
}
|
|
|
|
func (s *Server) setSessionCookie(w http.ResponseWriter, token string, ttl time.Duration) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: s.cookieName(),
|
|
Value: token,
|
|
Path: "/",
|
|
// httpOnly: the frontend never reads this, so a cross-site script that
|
|
// gets to run cannot exfiltrate the session.
|
|
HttpOnly: true,
|
|
Secure: s.secureCookies(),
|
|
// Lax rather than Strict: Strict would mean following an invite link from
|
|
// an email lands you logged out, which looks exactly like a broken invite.
|
|
SameSite: http.SameSiteLaxMode,
|
|
Expires: time.Now().Add(ttl),
|
|
MaxAge: int(ttl.Seconds()),
|
|
})
|
|
}
|
|
|
|
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: s.cookieName(),
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: s.secureCookies(),
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: -1,
|
|
})
|
|
}
|
|
|
|
// requireAuth rejects anything without a complete login.
|
|
//
|
|
// A session still waiting on its TOTP code is refused here with totp_required, so
|
|
// the two-step login cannot be skipped by simply calling the endpoint you wanted.
|
|
func (s *Server) requireAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
user, session, err := s.authenticate(r)
|
|
if err != nil {
|
|
// A database that could not be read is not a person who is not signed
|
|
// in. Answering 401 for both means an infrastructure problem shows up
|
|
// as everybody being logged out at once — the frontend clears its state
|
|
// and shows the sign-in screen — and nothing in the logs says why.
|
|
if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) {
|
|
s.internal(w, r, "authenticate", err)
|
|
return
|
|
}
|
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in")
|
|
return
|
|
}
|
|
if session != nil && session.PendingTOTP {
|
|
writeError(w, http.StatusUnauthorized, CodeTOTPRequired,
|
|
"this login still needs its verification code")
|
|
return
|
|
}
|
|
ctx := context.WithValue(r.Context(), ctxUser, user)
|
|
if session != nil {
|
|
ctx = context.WithValue(ctx, ctxSession, session)
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// requirePendingSession guards the second step of a login, and nothing else.
|
|
func (s *Server) requirePendingSession(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
user, session, err := s.authenticate(r)
|
|
if err != nil || session == nil || !session.PendingTOTP {
|
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "no login is in progress")
|
|
return
|
|
}
|
|
ctx := context.WithValue(r.Context(), ctxUser, user)
|
|
ctx = context.WithValue(ctx, ctxSession, session)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
func (s *Server) requireAdmin(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if u := userFrom(r.Context()); u == nil || !u.IsAdmin {
|
|
writeError(w, http.StatusForbidden, CodeForbidden, "administrator access is required")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// authenticate resolves a request to a user, by session cookie, by API token, or by
|
|
// an app's session sent as a bearer token.
|
|
//
|
|
// All are accepted everywhere. An API token belongs to a person and carries their
|
|
// permissions exactly — there is no separate service identity to reason about, and
|
|
// a script therefore cannot reach anything its owner could not.
|
|
// errNoCredentials means the request carried nothing to authenticate with, as
|
|
// opposed to carrying something that turned out to be wrong — or to the lookup
|
|
// itself failing, which callers must not treat as a failed login.
|
|
var errNoCredentials = errors.New("no credentials")
|
|
|
|
func (s *Server) authenticate(r *http.Request) (*store.User, *store.Session, error) {
|
|
if token := bearerToken(r); token != "" && auth.IsAPIToken(token) {
|
|
user, err := s.db.UserByAPIToken(r.Context(), token)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return user, nil, nil
|
|
}
|
|
|
|
// Any other bearer is an app session (see loginResponse.SessionToken). It is
|
|
// accepted only for a session that was issued to the app: a browser's session
|
|
// never leaves its httpOnly cookie, and a cookie value found somewhere and
|
|
// replayed as a header must not become a second way to use it.
|
|
if token := bearerToken(r); token != "" {
|
|
session, user, err := s.db.SessionByToken(r.Context(), token)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if session.Client != "app" {
|
|
return nil, nil, store.ErrNotFound
|
|
}
|
|
return user, session, nil
|
|
}
|
|
|
|
cookie, err := r.Cookie(s.cookieName())
|
|
if err != nil || cookie.Value == "" {
|
|
return nil, nil, errNoCredentials
|
|
}
|
|
session, user, err := s.db.SessionByToken(r.Context(), cookie.Value)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return user, session, nil
|
|
}
|
|
|
|
func bearerToken(r *http.Request) string {
|
|
h := r.Header.Get("Authorization")
|
|
if len(h) > 7 && strings.EqualFold(h[:7], "bearer ") {
|
|
return strings.TrimSpace(h[7:])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// withUser puts a user on the context. Used by the CalDAV handlers, which
|
|
// authenticate with Basic rather than through the session middleware.
|
|
func withUser(ctx context.Context, user *store.User) context.Context {
|
|
return context.WithValue(ctx, ctxUser, user)
|
|
}
|
|
|
|
func userFrom(ctx context.Context) *store.User {
|
|
u, _ := ctx.Value(ctxUser).(*store.User)
|
|
return u
|
|
}
|
|
|
|
func sessionFrom(ctx context.Context) *store.Session {
|
|
s, _ := ctx.Value(ctxSession).(*store.Session)
|
|
return s
|
|
}
|
|
|
|
// requireCSRF blocks cross-site form posts.
|
|
//
|
|
// SameSite=Lax already stops the browser sending the cookie on a cross-site POST,
|
|
// which covers current browsers. This is the second lock: a state-changing request
|
|
// must either be same-origin by its own headers or carry an API token, which a
|
|
// cross-site form cannot set. Checked here rather than with a token in every form,
|
|
// because a token the frontend has to remember to include is a token it will one
|
|
// day forget.
|
|
func (s *Server) requireCSRF(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
// A bearer token cannot be attached by a cross-site form or image tag.
|
|
if bearerToken(r) != "" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
// The iOS app, before it has a token: its sign-in. A web page cannot send
|
|
// this Origin — the browser writes the header, not the page — and an app
|
|
// login sets no cookie, so there is no session here for a forged request
|
|
// to ride on.
|
|
if s.isAppOrigin(r.Header.Get("Origin")) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Sec-Fetch-Site is set by the browser and cannot be forged by page script.
|
|
switch r.Header.Get("Sec-Fetch-Site") {
|
|
case "same-origin", "same-site", "none":
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
case "cross-site":
|
|
writeError(w, http.StatusForbidden, CodeForbidden, "cross-site request refused")
|
|
return
|
|
}
|
|
|
|
// Older clients send no Sec-Fetch-Site; fall back to comparing Origin.
|
|
if origin := r.Header.Get("Origin"); origin != "" && !s.isOwnOrigin(origin) {
|
|
writeError(w, http.StatusForbidden, CodeForbidden, "cross-site request refused")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// appOrigins are where the iOS app's interface is served from. Capacitor loads
|
|
// the bundled build on this scheme inside WKWebView, so every request the app
|
|
// makes to a server arrives from here, whichever server it is.
|
|
var appOrigins = []string{"capacitor://localhost"}
|
|
|
|
func (s *Server) isAppOrigin(origin string) bool {
|
|
if origin == "" {
|
|
return false
|
|
}
|
|
for _, o := range appOrigins {
|
|
if origin == o {
|
|
return true
|
|
}
|
|
}
|
|
for _, o := range s.cfg.AppOrigins {
|
|
if origin == o {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Server) isOwnOrigin(origin string) bool {
|
|
if strings.EqualFold(origin, s.cfg.BaseURL) {
|
|
return true
|
|
}
|
|
// A local dev frontend on another port talks to the API directly.
|
|
return s.cfg.Dev && strings.HasPrefix(origin, "http://localhost:")
|
|
}
|
|
|
|
// requireProject checks the caller's standing in the {projectID} of the route
|
|
// before any handler under it runs.
|
|
//
|
|
// Doing this as middleware rather than as a first line in each handler is what
|
|
// makes it hard to forget: a new endpoint added under the route is guarded by
|
|
// virtue of where it sits, not by the author remembering to add a check.
|
|
func (s *Server) requireProject(min store.Role) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "projectID")
|
|
user := userFrom(r.Context())
|
|
if user == nil || projectID == "" {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, min); err != nil {
|
|
// 404 rather than 403: a 403 confirms the project exists, which
|
|
// is what somebody trying ids is hoping to learn.
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|