carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

1package auth
2
3import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/base64"
8 "encoding/hex"
9 "errors"
10 "net/http"
11 "strings"
12 "time"
13
14 "gorm.io/gorm"
15
16 "github.com/carlosframework/rastrillo/sessions"
17
18 "github.com/carlosframework/contra/internal/contra/model"
19 "github.com/carlosframework/contra/internal/contra/store"
20)
21
22// Signup in three steps: prove the address, choose a passphrase, then
23// optionally add a passkey.
24//
25// It replaced a single screen that did everything between one button press
26// and one result. That screen was wrong for two reasons and both matter.
27//
28// The small one: it took six seconds and could fail in a dozen places, and
29// when it did the person got one sentence from whichever operation happened
30// to throw. Steps that each report their own outcome are diagnosable.
31//
32// The large one: nothing proved the address. Anyone could claim any address's
33// blind index, and the person who owned it could then never register. This is
34// the direction AGENTS.md decision 3 already committed to — "email + magic
35// link for signup, then passkey for quick sign-in thereafter, is intended to
36// become the CARLOS default. CONTRA is where it gets built first."
37//
38// # What the server learns
39//
40// Nothing new. The address is used to address one message and is not written
41// down; the row holds the blind index and a hash of the emailed token. A
42// database-plus-bucket leak yields nothing anyone can click.
43
44// SignupTTL is how long a link is good for. Long enough to walk to another
45// device and open the mail, short enough that an old message in an archive is
46// not a way in.
47const SignupTTL = 30 * time.Minute
48
49// signupSubject prefixes the session Subject of somebody who has proved an
50// address but has no account yet.
51//
52// Deliberately not a bare hex id: every other subject in this app is an
53// account id, and vault.accountID decodes exactly 16 bytes — so a signup
54// session cannot reach a vault route by accident. It fails the length check
55// and answers 404, which is the correct answer to "show me the vault of an
56// account that does not exist".
57const signupSubject = "signup:"
58
59var ErrNoSignupSession = errors.New("auth: no verified signup in progress")
60
61func newToken() (token string, hash []byte, err error) {
62 raw := make([]byte, 32)
63 if _, err := rand.Read(raw); err != nil {
64 return "", nil, err
65 }
66 sum := sha256.Sum256(raw)
67 return base64.RawURLEncoding.EncodeToString(raw), sum[:], nil
68}
69
70func hashToken(token string) ([]byte, error) {
71 raw, err := base64.RawURLEncoding.DecodeString(token)
72 if err != nil || len(raw) != 32 {
73 return nil, errors.New("auth: malformed token")
74 }
75 sum := sha256.Sum256(raw)
76 return sum[:], nil
77}
78
79type beginRequest struct {
80 Email string `json:"email"`
81}
82
83// Begin takes an address, emails it a link, and says the same thing either
84// way.
85//
86// The response never varies on whether an account exists, whether a signup is
87// already in flight, or whether the message was delivered — because all three
88// are facts about somebody else's account when the asker is not that person.
89// What varies is which message gets sent.
90func (a *Auth) Begin(w http.ResponseWriter, r *http.Request) {
91 var req beginRequest
92 if !readJSON(w, r, &req) {
93 return
94 }
95 email := NormalizeEmail(req.Email)
96 if email == "" || !strings.Contains(email, "@") {
97 writeJSON(w, http.StatusBadRequest,
98 map[string]string{"error": "that does not look like an email address"})
99 return
100 }
101
102 ctx := r.Context()
103 index := a.BlindIndex(email)
104
105 // Whatever happens below, the answer is this.
106 answer := func() {
107 writeJSON(w, http.StatusOK, map[string]string{
108 "status": "sent",
109 "detail": "If that address can be signed up, a link is on its way.",
110 })
111 }
112
113 var existing model.Account
114 err := a.db.WithContext(ctx).Where("email_index = ?", index).First(&existing).Error
115 if err == nil {
116 // Already registered. Say so to the address itself and nowhere else:
117 // the person who owns it learns something useful, and a prober learns
118 // nothing at all.
119 a.send(ctx, email, "Your CONTRA account",
120 "Somebody asked to create a CONTRA account with this address, and "+
121 "one already exists.\n\nIf that was you, sign in instead:\n\n "+
122 a.origin+"/signin\n\nIf it was not you, nothing has happened and "+
123 "there is nothing you need to do.\n")
124 answer()
125 return
126 } else if !errors.Is(err, gorm.ErrRecordNotFound) {
127 writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "lookup failed"})
128 return
129 }
130
131 // One live signup per address: a second request replaces the first, so an
132 // older link in an inbox stops working.
133 if err := a.db.WithContext(ctx).
134 Where("email_index = ?", index).Delete(&model.SignupSession{}).Error; err != nil {
135 writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not start"})
136 return
137 }
138
139 token, hash, err := newToken()
140 if err != nil {
141 writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no randomness"})
142 return
143 }
144 id, err := store.NewID()
145 if err != nil {
146 writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no randomness"})
147 return
148 }
149 if err := a.db.WithContext(ctx).Create(&model.SignupSession{
150 ID: id, EmailIndex: index, TokenHash: hash,
151 ExpiresAt: time.Now().UTC().Add(SignupTTL), CreatedAt: time.Now().UTC(),
152 }).Error; err != nil {
153 writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not start"})
154 return
155 }
156
157 a.send(ctx, email, "Finish setting up CONTRA",
158 "Open this link to carry on setting up your CONTRA account:\n\n "+
159 a.origin+"/signup/verify?t="+token+"\n\nIt works once and expires in "+
160 "30 minutes.\n\nIf you did not ask for this, ignore it — nothing has "+
161 "been created, and this address has not been stored.\n")
162 answer()
163}
164
165// send never fails a request. A person who did not get the mail retries; a
166// person who did should not be told that delivery worked before it has, and a
167// 500 here would tell a prober that this address was the interesting one.
168func (a *Auth) send(ctx context.Context, to, subject, body string) {
169 if a.mailer == nil {
170 a.logger.Error("auth: no mailer configured; a signup link was not sent")
171 return
172 }
173 ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 20*time.Second)
174 defer cancel()
175 if err := a.mailer.Send(ctx, to, subject, body); err != nil {
176 // The address is not logged. Delivery failures are a real operational
177 // signal and still not worth keeping a list of who we mailed.
178 a.logger.Error("auth: sending a signup link failed", "err", err)
179 }
180}
181
182// VerifyLink is the link's landing. It marks the signup verified and mints a
183// session that can do exactly one thing: finish signing up.
184func (a *Auth) VerifyLink(w http.ResponseWriter, r *http.Request) {
185 hash, err := hashToken(r.URL.Query().Get("t"))
186 if err != nil {
187 http.Redirect(w, r, "/signup?e=badlink", http.StatusSeeOther)
188 return
189 }
190
191 ctx := r.Context()
192 var row model.SignupSession
193 if err := a.db.WithContext(ctx).Where("token_hash = ?", hash).First(&row).Error; err != nil {
194 http.Redirect(w, r, "/signup?e=badlink", http.StatusSeeOther)
195 return
196 }
197 if time.Now().UTC().After(row.ExpiresAt) {
198 a.db.WithContext(ctx).Delete(&model.SignupSession{}, "id = ?", row.ID)
199 http.Redirect(w, r, "/signup?e=expired", http.StatusSeeOther)
200 return
201 }
202 if err := a.db.WithContext(ctx).Model(&model.SignupSession{}).
203 Where("id = ?", row.ID).Update("verified", true).Error; err != nil {
204 http.Redirect(w, r, "/signup?e=failed", http.StatusSeeOther)
205 return
206 }
207
208 if err := a.sess.SignIn(w, r, sessions.Session{
209 Subject: signupSubject + hex.EncodeToString(row.ID),
210 Method: "signup-link",
211 AuthTime: time.Now().UTC(),
212 }); err != nil {
213 http.Redirect(w, r, "/signup?e=failed", http.StatusSeeOther)
214 return
215 }
216 http.Redirect(w, r, "/signup/passphrase", http.StatusSeeOther)
217}
218
219// SignupSessionOf returns the verified signup behind this request, if any.
220func (a *Auth) SignupSessionOf(r *http.Request) (*model.SignupSession, error) {
221 sess, ok := sessions.Current(r)
222 if !ok || !strings.HasPrefix(sess.Subject, signupSubject) {
223 return nil, ErrNoSignupSession
224 }
225 id, err := hex.DecodeString(strings.TrimPrefix(sess.Subject, signupSubject))
226 if err != nil || len(id) != 16 {
227 return nil, ErrNoSignupSession
228 }
229 var row model.SignupSession
230 if err := a.db.WithContext(r.Context()).
231 Where("id = ? AND verified = ?", id, true).First(&row).Error; err != nil {
232 return nil, ErrNoSignupSession
233 }
234 if time.Now().UTC().After(row.ExpiresAt) {
235 return nil, ErrNoSignupSession
236 }
237 return &row, nil
238}
239
240// Complete is step two: the browser has done all the crypto and posts only
241// sealed material. The account is created here, against the address that was
242// actually proved, and the signup session is spent.
243func (a *Auth) Complete(w http.ResponseWriter, r *http.Request) {
244 pending, err := a.SignupSessionOf(r)
245 if err != nil {
246 writeJSON(w, http.StatusForbidden, map[string]string{
247 "error": "That link has expired or was already used. Start again."})
248 return
249 }
250
251 var req signupRequest
252 if !readJSON(w, r, &req) {
253 return
254 }
255 salt, e1 := hex.DecodeString(req.SaltHex)
256 accountID, e2 := hex.DecodeString(req.AccountIDHex)
257 if e1 != nil || e2 != nil || len(salt) != 16 || len(accountID) != 16 {
258 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad account material"})
259 return
260 }
261 for _, wr := range req.Wraps {
262 if err := CheckParams(wr.KDFParams); err != nil {
263 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
264 return
265 }
266 }
267
268 ctx := r.Context()
269 acct, err := a.store.CreateAccount(ctx, accountID, pending.EmailIndex, salt)
270 if err != nil {
271 // The unique index on email_index is what makes this a conflict
272 // rather than a second account for one address.
273 writeJSON(w, http.StatusConflict,
274 map[string]string{"error": ErrTaken.Error()})
275 return
276 }
277 if err := a.storeCredentials(ctx, acct.ID, req); err != nil {
278 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
279 return
280 }
281
282 // Spent. The link cannot be followed again and the row stops being a
283 // record that this address ever signed up.
284 a.db.WithContext(ctx).Delete(&model.SignupSession{}, "id = ?", pending.ID)
285
286 if err := a.sess.SignIn(w, r, sessions.Session{
287 Subject: hex.EncodeToString(acct.ID),
288 Method: "signup",
289 AuthTime: time.Now().UTC(),
290 }); err != nil {
291 writeJSON(w, http.StatusInternalServerError,
292 map[string]string{"error": "the account was created but the session was not"})
293 return
294 }
295 writeJSON(w, http.StatusCreated, map[string]string{
296 "account_id_hex": hex.EncodeToString(acct.ID)})
297}
298
299// SweepSignups deletes expired rows.
300func (a *Auth) SweepSignups(now time.Time) error {
301 _, err := SweepSignupsIn(context.Background(), a.db, now)
302 return err
303}
304
305// SweepSignupsIn is the same delete without an Auth to hang it on, so the
306// background sweep can run without building the whole ceremony to reach it.
307//
308// A half-finished signup is already inert — every lookup carries the expiry —
309// so this is not about correctness. It is that a table of these that
310// accumulated would be a log of who tried to sign up and when.
311func SweepSignupsIn(ctx context.Context, db *gorm.DB, now time.Time) (int64, error) {
312 res := db.WithContext(ctx).
313 Where("expires_at < ?", now.UTC()).Delete(&model.SignupSession{})
314 return res.RowsAffected, res.Error
315}
316