package web import ( "crypto/rand" "crypto/subtle" "encoding/hex" "net/http" "sync" "time" "gitea.mynodes.xyz/ilker/llmgw/internal/clientip" ) // The sign-in and signup forms are the two a browser posts before it has a // session: nothing ties them to the person in front of it, and nothing slows // a script posting them. Two guards: a token the form carries and a cookie // matches, so another site cannot sign a visitor into an account of its // choosing; and a ration per client address, so a script cannot spend the // password hasher, the database or an address's attempts at the rate it can // send. // loginCSRFCookie is the sign-in form's token, before there is a session. const loginCSRFCookie = "llmgw_login_csrf" // newFormToken is a token for one form. func newFormToken() string { b := make([]byte, 16) _, _ = rand.Read(b) return hex.EncodeToString(b) } // setLoginToken gives the sign-in form its token and the browser its match. func setLoginToken(w http.ResponseWriter, r *http.Request) string { t := newFormToken() http.SetCookie(w, &http.Cookie{ Name: loginCSRFCookie, Value: t, Path: "/login", HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteStrictMode, MaxAge: 3600, }) return t } // loginTokenOK reports whether a posted sign-in form carries the token its // cookie does. func loginTokenOK(r *http.Request) bool { c, err := r.Cookie(loginCSRFCookie) if err != nil || c.Value == "" { return false } return subtle.ConstantTimeCompare([]byte(c.Value), []byte(r.FormValue("csrf"))) == 1 } // ration is a token bucket per client address. type ration struct { mu sync.Mutex rate float64 // a second burst float64 by map[string]*allowance } type allowance struct { tokens float64 at time.Time } func newRation(perMinute, burst float64) *ration { return &ration{rate: perMinute / 60, burst: burst, by: map[string]*allowance{}} } func (q *ration) allow(client string, now time.Time) bool { q.mu.Lock() defer q.mu.Unlock() if len(q.by) > 50_000 { for k, v := range q.by { if now.Sub(v.at).Seconds()*q.rate >= q.burst { delete(q.by, k) } } } a, ok := q.by[client] if !ok { a = &allowance{tokens: q.burst, at: now} q.by[client] = a } a.tokens = min(q.burst, a.tokens+now.Sub(a.at).Seconds()*q.rate) a.at = now if a.tokens < 1 { return false } a.tokens-- return true } // rationed refuses a client past its ration with 429 before next runs. func rationed(q *ration, next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !q.allow(clientAddr(r), time.Now()) { w.Header().Set("Retry-After", "60") // Plain text, but read by the person whose form was refused. http.Error(w, langOf(r).T("login.err.rationed"), http.StatusTooManyRequests) return } next(w, r) } } // clientAddr is the address a request came from: through a trusted proxy, // the client's. func clientAddr(r *http.Request) string { return clientip.Addr(r) }