carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1package contra
2
3import (
4 "context"
5 "log/slog"
6 "net/http"
7
8 "github.com/go-chi/chi/v5"
9
10 "github.com/carlosframework/rastrillo/csrf"
11 "github.com/carlosframework/rastrillo/db"
12 "github.com/carlosframework/rastrillo/migrate"
13 "github.com/carlosframework/rastrillo/sessions"
14
15 "github.com/carlosframework/contra/internal/contra/auth"
16 "github.com/carlosframework/contra/internal/contra/mail"
17 "github.com/carlosframework/contra/internal/contra/vault"
18)
19
20// Config is what App needs from the outside. Pepper is separated from the
21// rest because it is the one secret the instance holds, and it is worth
22// seeing that it arrives from a caller rather than being read out of the
23// environment somewhere in the middle of the wiring.
24type Config struct {
25 // Origin is the app's external origin, scheme included. It decides the
26 // CSRF check's yardstick and the session cookie's Secure/__Host-
27 // attributes, so defaulting it silently in production is a real bug.
28 Origin string
29
30 // Mailer sends the signup link. Nil boots with a loud warning rather than
31 // refusing, so a local run without SMTP still works — but nobody can sign
32 // up, which is worth saying at startup rather than at the first attempt.
33 Mailer mail.Sender
34
35 // Pepper blinds the email index. It proves identity and decrypts
36 // nothing; PROTOCOL.md wants it moved off the instance to the routing
37 // layer or a KMS, so that a database-plus-bucket leak alone cannot
38 // unblind an address.
39 Pepper []byte
40}
41
42// App wires the whole app: schema, sessions, auth, router, static files. It
43// returns a *http.ServeMux because rastrillo.Options.Mux is typed that way —
44// the chi router mounts inside it.
45func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, error) {
46 // Apply BootSchema, not Schema: BootSchema is everything this app needs
47 // at boot (its own migrations plus any subsystem's), while Schema stays
48 // just this app's own so the generator and its check never see a
49 // subsystem's tables and try to drop them.
50 migrated, err := migrate.Apply(context.Background(), d, BootSchema)
51 if err != nil {
52 return nil, err
53 }
54 logger.Info("migrate", "applied", migrated.Applied,
55 "skipped", migrated.Skipped, "adopted", migrated.Adopted)
56
57 writer, err := d.G.DB()
58 if err != nil {
59 return nil, err
60 }
61 sess, err := sessions.New(sessions.Config{
62 DB: writer, Origin: cfg.Origin, Logger: logger, SigninPath: "/signin",
63 })
64 if err != nil {
65 return nil, err
66 }
67
68 ident, err := auth.New(d.G, sess, cfg.Pepper, cfg.Mailer, cfg.Origin, logger)
69 if err != nil {
70 return nil, err
71 }
72
73 a := &app{db: d, logger: logger, sess: sess, auth: ident}
74
75 r := chi.NewRouter()
76 // App-wide, from day one: every state-changing route added below is born
77 // covered. Origin-checking, not tokens — nothing to mint or forget.
78 r.Use(csrf.Protect(cfg.Origin))
79 // Resolves a session when there is one and blocks nothing, so the
80 // marketing page can tell a signed-in visitor from a stranger without
81 // any route becoming a gate by accident.
82 r.Use(sess.Middleware)
83
84 r.Get("/", a.index)
85 r.Get("/signin", a.signinPage)
86
87 // Signup, in steps. Each page is its own screen with its own errors,
88 // because the single screen this replaced did six seconds of work behind
89 // one button and reported whatever happened to throw.
90 r.Get("/signup", a.signupStart) // 1. the address
91 r.Get("/signup/sent", a.signupSent) // 1a. go and read your mail
92 r.Get("/signup/verify", ident.VerifyLink) // 1b. the link lands here
93 r.Get("/signup/passphrase", a.signupPassphrase) // 2. choose one
94 r.Get("/signup/passkey", a.signupPasskey) // 3. optional, and skippable
95
96 // The ceremony. Every one of these carries public keys, sealed blobs or
97 // an opaque index — never a passphrase, never a key, never an address in
98 // a form the server keeps.
99 r.Post("/auth/salt", ident.Salt)
100 r.Post("/auth/begin", ident.Begin)
101 r.Post("/auth/complete", ident.Complete)
102 r.Post("/auth/challenge", ident.Challenge)
103 r.Post("/auth/verify", ident.Verify)
104 r.Post("/auth/signout", ident.SignOut)
105
106 items := vault.New(d.G)
107 r.Group(func(r chi.Router) {
108 r.Use(sess.Require)
109 r.Get("/vault", a.vault)
110
111 // The item API. Every one of these hands out or takes in ciphertext,
112 // scoped to the session's account inside the SQL rather than beside
113 // it, and answers 404 for a row that is not yours.
114 r.Get("/api/bootstrap", items.Bootstrap)
115 r.Post("/api/provision", items.Provision)
116 r.Get("/api/items", items.Items)
117 r.Post("/api/items", items.CreateItem)
118 r.Put("/api/items", items.UpdateItem)
119 r.Post("/api/items/delete", items.DeleteItem)
120 r.Post("/api/passkey", items.AddPasskey)
121
122 // Sharing. AccountKey is the lookup that makes it possible: by
123 // account id, never by address, because an address-to-account
124 // endpoint would be the existence oracle the blind index exists to
125 // prevent. Removal has no route of its own — it is a rotation with a
126 // shorter member list, because in a seal-to-public-key model
127 // revocation IS rotation.
128 r.Get("/api/account-key", items.AccountKey)
129 r.Get("/api/members", items.Members)
130 r.Post("/api/members", items.AddMember)
131 r.Post("/api/vault/rotate", items.Rotate)
132
133 // The printed kit: reprint the Account Key card exactly, or reissue
134 // the recovery code — which regenerates rather than reprints, because
135 // only its wrap is stored. PROTOCOL.md wants this obvious rather than
136 // buried: it is the mitigation for the one loss mode the Account Key
137 // introduced.
138 r.Get("/kit", a.kit)
139 r.Post("/api/recovery", items.RegenerateRecovery)
140
141 // The device handoff, PROTOCOL.md's rung 1. Both ends are signed in as
142 // the same account — a fresh browser signs in with the passphrase
143 // alone and only then finds it has no Account Key — so every one of
144 // these is scoped to the session, which is what makes a code short
145 // enough to read aloud safe to use.
146 r.Get("/device", a.device)
147 r.Post("/api/link/start", items.StartLink)
148 r.Get("/api/link/pending", items.PendingLink)
149 r.Post("/api/link/approve", items.ApproveLink)
150 r.Get("/api/link/claim", items.ClaimLink)
151 r.Post("/api/link/cancel", items.CancelLink)
152 })
153
154 mux := http.NewServeMux()
155 // The app serves its own static files — the framework never does. They
156 // are embedded (render.go) and fingerprinted: templates link them via
157 // {{asset ...}}, which returns a content-hashed URL served
158 // cacheable-forever, while the bare name stays no-cache so the ES module
159 // graph can import by relative path.
160 mux.Handle("GET /static/", assets.Handler())
161 mux.Handle("/", r)
162 return mux, nil
163}
164