diff options
| author | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
| commit | bb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch) | |
| tree | 6d5eaf5dac213074cd832da50f64260fce728fed /auth.go | |
| download | git-forge-master.tar.gz | |
A small self-hosted git forge: HTTP push and pull, cgit browsing,
local and OIDC accounts, TOTP, scoped push tokens, per-repo
permissions, issues, a wiki, and outbound mail.
It does not reimplement git. git-http-backend and cgit run as CGI
behind its own listener, which authorises every request before
exec'ing either. One binary, one data directory, one listener: no
database, no JavaScript, and state is JSON in a git repo with a
working tree.
Diffstat (limited to 'auth.go')
| -rw-r--r-- | auth.go | 510 |
1 files changed, 510 insertions, 0 deletions
@@ -0,0 +1,510 @@ +package main + +// The primitives: hashing, signing, cookies, one time links, TOTP, push +// tokens, and rate limits. + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "crypto/subtle" + "encoding/base32" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/crypto/argon2" +) + +// Password hashing parameters, stored in every hash, so old hashes keep +// verifying. +const ( + argonTime = 3 + argonMemory = 64 * 1024 // KiB + argonThreads = 4 + argonSaltLen = 16 + argonKeyLen = 32 + + minPassword = 8 +) + +const ( + sessionCookie = "session" + sessionLifetime = 30 * 24 * time.Hour +) + +// What a form may weigh; field limits are the handlers' own. +const ( + // Percent encoding can treble a byte, and the largest field is maxBody. + maxForm = 4*maxBody + 64<<10 + // Both images at their limit plus one a little over, so it still gets the + // sentence saying so. + maxUpload = 3 * maxImage +) + +// argon2id is expensive in memory by design, so concurrent hashes are bounded: +// otherwise an unauthenticated POST to /login sets the peak. +var hashing = make(chan struct{}, 2) + +func hashSlot() func() { + hashing <- struct{}{} + return func() { <-hashing } +} + +// hashPassword returns a PHC string: $argon2id$v=19$m=...,t=...,p=...$salt$hash. +func hashPassword(password string) string { + defer hashSlot()() + + salt := make([]byte, argonSaltLen) + rand.Read(salt) // crypto/rand does not fail, it panics if the kernel does + key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + b64 := base64.RawStdEncoding.EncodeToString + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, argonMemory, argonTime, argonThreads, b64(salt), b64(key)) +} + +// passwordOK hashes even when the encoding is unusable, so a wrong account +// name costs the same as a wrong password. +func passwordOK(encoded, password string) bool { + defer hashSlot()() + + salt, want, time, memory, threads, err := parseHash(encoded) + if err != nil { + salt, want, time, memory, threads = make([]byte, argonSaltLen), make([]byte, argonKeyLen), argonTime, argonMemory, argonThreads + } + got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want))) + return err == nil && subtle.ConstantTimeCompare(got, want) == 1 +} + +func parseHash(encoded string) (salt, key []byte, time, memory uint32, threads uint8, err error) { + fail := func(why string) ([]byte, []byte, uint32, uint32, uint8, error) { + return nil, nil, 0, 0, 0, fmt.Errorf("argon2id hash: %s", why) + } + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" { + return fail("not a $argon2id$ string") + } + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { + return fail("unsupported version") + } + var m, t uint32 + var p uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { + return fail("unreadable parameters") + } + salt, err = base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return fail("unreadable salt") + } + key, err = base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil || len(key) == 0 { + return fail("unreadable hash") + } + return salt, key, t, m, p, nil +} + +// mac signs fields for one purpose, so a session cookie is never a reset link; +// the separator keeps two field lists apart. +func (f *Forge) mac(purpose string, fields ...string) string { + h := hmac.New(sha256.New, f.store.cookieKey) + h.Write([]byte(purpose)) + for _, s := range fields { + h.Write([]byte{0}) + h.Write([]byte(s)) + } + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) +} + +// There is no session store: the cookie carries the name and expiry, and the +// epoch is mixed into the signature, so bumping it invalidates every cookie. +func (f *Forge) setSession(w http.ResponseWriter, name string, epoch int) string { + expiry := time.Now().Add(sessionLifetime) + unix := strconv.FormatInt(expiry.Unix(), 10) + value := base64.RawURLEncoding.EncodeToString([]byte(name)) + "." + unix + "." + + f.mac("session", name, unix, strconv.Itoa(epoch)) + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: value, + Path: "/", + Expires: expiry, + MaxAge: int(sessionLifetime / time.Second), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: strings.HasPrefix(f.cfg.BaseURL, "https://"), + }) + return value +} + +func (f *Forge) clearSession(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: strings.HasPrefix(f.cfg.BaseURL, "https://"), + }) +} + +func (f *Forge) user(r *http.Request) (string, User, bool) { + c, err := r.Cookie(sessionCookie) + if err != nil { + return "", User{}, false + } + parts := strings.Split(c.Value, ".") + if len(parts) != 3 { + return "", User{}, false + } + raw, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", User{}, false + } + unix, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || time.Now().After(time.Unix(unix, 0)) { + return "", User{}, false + } + name := string(raw) + u, ok := f.store.User(name) + if !ok { + return "", User{}, false + } + want := f.mac("session", name, parts[1], strconv.Itoa(u.Epoch)) + if !hmac.Equal([]byte(parts[2]), []byte(want)) { + return "", User{}, false + } + return name, u, true +} + +// csrf is the form token, bound to the session cookie. +func (f *Forge) csrf(r *http.Request) string { + c, err := r.Cookie(sessionCookie) + if err != nil { + return f.mac("csrf", "") + } + return f.mac("csrf", c.Value) +} + +// checkPost gates the HTML forms, not the git endpoints. Signed in, the form +// token is required; signed out, only the Origin header can say, and a client +// sending none is let through. +func (f *Forge) checkPost(w http.ResponseWriter, r *http.Request) bool { + // ParseMultipartForm's argument bounds memory only; the rest goes to a + // temporary file, so MaxBytesReader is what bounds the disk. + var err error + if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { + r.Body = http.MaxBytesReader(w, r.Body, maxUpload) + err = r.ParseMultipartForm(maxImage) + } else { + r.Body = http.MaxBytesReader(w, r.Body, maxForm) + err = r.ParseForm() + } + if err != nil { + f.fail(w, r, http.StatusBadRequest, "unreadable form, or one too big to be one") + return false + } + if origin := r.Header.Get("Origin"); origin != "" && origin != f.cfg.BaseURL { + f.fail(w, r, http.StatusForbidden, "cross-site post refused") + return false + } + if _, _, ok := f.user(r); ok { + if !hmac.Equal([]byte(r.PostFormValue("csrf")), []byte(f.csrf(r))) { + f.fail(w, r, http.StatusForbidden, "stale form, reload the page and try again") + return false + } + } + return true +} + +// maxTokens bounds users.json and the audit log, and keeps a three byte id +// unambiguous. +const maxTokens = 32 + +// Only the digest is kept. +func mintToken(repo string) (secret string, token Token) { + secret = randomToken(24) + return secret, Token{ + ID: randomToken(3), Repo: repo, Hash: tokenHash(secret), Created: today(), + } +} + +// today is the granularity every date on a token is kept at. +func today() string { return time.Now().UTC().Format("2006-01-02") } + +// tokenHash is SHA-256, not argon2id: a token is high entropy, and this runs +// on every git request. +func tokenHash(secret string) string { + sum := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(sum[:]) +} + +// checkToken returns the account's own name, not the one typed: grants and +// REMOTE_USER key off it. +func (f *Forge) checkToken(given, secret, repo string) (string, User, bool) { + name := accountName(given) + u, ok := f.store.User(name) + if !ok { + return "", User{}, false + } + want := tokenHash(secret) + for _, t := range u.Tokens { + if t.Repo == repo && hmac.Equal([]byte(t.Hash), []byte(want)) { + f.touchToken(name, t) + return name, u, true + } + } + return "", User{}, false +} + +// touchToken writes once a day: git makes several requests per clone. +func (f *Forge) touchToken(name string, used Token) { + if used.Used == today() { + return + } + err := f.store.touchUser(name, func(acct *User) error { + for i := range acct.Tokens { + if acct.Tokens[i].ID == used.ID { + acct.Tokens[i].Used = today() + } + } + return nil + }) + if err != nil { + log.Printf("tokens: %v", err) // not worth failing a push over + } +} + +// TOTP is RFC 6238 with the parameters every authenticator assumes: HMAC-SHA1, +// six digits, thirty seconds. +const ( + totpDigits = 6 + totpPeriod = 30 * time.Second + totpSkew = 1 // steps either side + totpSecretLen = 20 + recoveryCodes = 10 + totpPendLife = 5 * time.Minute +) + +// totpSecret is base32 without padding, as an otpauth URI carries it. +func totpSecret() string { + raw := make([]byte, totpSecretLen) + rand.Read(raw) + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw) +} + +func totpCode(secret string, at time.Time) string { + raw, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret)) + if err != nil { + return "" + } + counter := make([]byte, 8) + binary.BigEndian.PutUint64(counter, uint64(at.Unix())/uint64(totpPeriod.Seconds())) + + mac := hmac.New(sha1.New, raw) + mac.Write(counter) + sum := mac.Sum(nil) + offset := sum[len(sum)-1] & 0x0f + value := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff + return fmt.Sprintf("%0*d", totpDigits, value%1000000) +} + +// validSecret is base32 of exactly the bytes this forge mints. The secret +// arrives in a form, so its strength is checked here. +func validSecret(secret string) bool { + raw, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) + return err == nil && len(raw) == totpSecretLen +} + +// totpStep is the time step the code is right for, or -1. One step either +// side of now is accepted, because clocks disagree. +func totpStep(secret, code string, now time.Time) int64 { + code = strings.TrimSpace(code) + for skew := -totpSkew; skew <= totpSkew; skew++ { + at := now.Add(time.Duration(skew) * totpPeriod) + want := totpCode(secret, at) + if want != "" && hmac.Equal([]byte(code), []byte(want)) { + return at.Unix() / int64(totpPeriod.Seconds()) + } + } + return -1 +} + +var errBadCode = errors.New("that code is not right, or was used already") + +// codeOK accepts a code once: the step it was accepted at is recorded on the +// account and a step at or below it is refused, so a code seen over a shoulder +// cannot be replayed within its window. Recorded without a commit, like a +// token's last use. A record that cannot be written refuses the code. +func (f *Forge) codeOK(name string, u User, code string) bool { + if u.TOTP == nil { + return false + } + step := totpStep(u.TOTP.Secret, code, time.Now()) + if step < 0 || step <= u.TOTP.Used { + return false + } + err := f.store.touchUser(name, func(acct *User) error { + if acct.TOTP == nil || step <= acct.TOTP.Used { + return errBadCode + } + acct.TOTP.Used = step + return nil + }) + if err != nil && !errors.Is(err, errBadCode) { + log.Printf("totp: %v", err) + } + return err == nil +} + +// totpURI is given as text: a QR code is useless in a terminal browser. +func (f *Forge) totpURI(name, secret string) string { + label := url.PathEscape(f.siteName() + ":" + name) + q := url.Values{ + "secret": {secret}, + "issuer": {f.siteName()}, + "algorithm": {"SHA1"}, + "digits": {strconv.Itoa(totpDigits)}, + "period": {strconv.Itoa(int(totpPeriod.Seconds()))}, + } + return "otpauth://totp/" + label + "?" + q.Encode() +} + +// newRecoveryCodes returns the codes to show once and the digests to keep. +func newRecoveryCodes() (show []string, keep []string) { + for range recoveryCodes { + code := randomToken(5) + show = append(show, code) + keep = append(keep, tokenHash(code)) + } + return show, keep +} + +func randomToken(n int) string { + b := make([]byte, n) + rand.Read(b) + return hex.EncodeToString(b) +} + +// A first password and a reset are the same link with different lives. +const ( + setPasswordLife = 48 * time.Hour + resetLife = time.Hour +) + +// passwordLink is stateless: a MAC over the account, an expiry, and the +// current password hash, so it dies when the password changes. +func (f *Forge) passwordLink(name string, u User, life time.Duration) string { + expires := strconv.FormatInt(time.Now().Add(life).Unix(), 10) + q := url.Values{ + "name": {name}, + "expires": {expires}, + "t": {f.mac("set-password", name, expires, u.Password)}, + } + return f.cfg.BaseURL + "/set-password?" + q.Encode() +} + +func (f *Forge) checkSetPassword(v url.Values) (string, User, bool) { + name, expires := v.Get("name"), v.Get("expires") + unix, err := strconv.ParseInt(expires, 10, 64) + if err != nil || time.Now().After(time.Unix(unix, 0)) { + return "", User{}, false + } + u, ok := f.store.User(name) + if !ok { + return "", User{}, false + } + want := f.mac("set-password", name, expires, u.Password) + if !hmac.Equal([]byte(v.Get("t")), []byte(want)) { + return "", User{}, false + } + return name, u, true +} + +const confirmEmailLife = 24 * time.Hour + +// confirmEmailLink is bound to the address, so changing it kills the link. +func (f *Forge) confirmEmailLink(name string, u User) string { + expires := strconv.FormatInt(time.Now().Add(confirmEmailLife).Unix(), 10) + q := url.Values{ + "name": {name}, + "expires": {expires}, + "t": {f.mac("confirm-email", name, expires, u.Email)}, + } + return f.cfg.BaseURL + "/confirm-email?" + q.Encode() +} + +// Every budget, per hour. Constants: nobody tunes these. +const ( + loginsPerHour = 60 + totpPerHour = 30 + resetsPerHour = 5 + issuesPerHour = 20 + commentsPerHour = 60 + // maxTokens bounds what is kept; this bounds what is written to get there. + tokensPerHour = 20 +) + +// limiter is a fixed window counter, per account and per address. In memory, +// so it resets on restart. +type limiter struct { + mu sync.Mutex + opened time.Time + counts map[string]int +} + +func newLimiter() *limiter { + return &limiter{opened: time.Now(), counts: map[string]int{}} +} + +func (l *limiter) hit(key string, perHour int) bool { + l.charge(key) + return !l.over(key, perHour) +} + +// charge counts; over asks without counting, for budgets spent only by +// failures. +func (l *limiter) charge(key string) { + l.mu.Lock() + defer l.mu.Unlock() + l.rollLocked() + l.counts[key]++ +} + +func (l *limiter) over(key string, perHour int) bool { + l.mu.Lock() + defer l.mu.Unlock() + l.rollLocked() + return l.counts[key] > perHour +} + +// Both callers roll the window: a budget spent only by failures never reaches +// charge once it has run out. +func (l *limiter) rollLocked() { + if time.Since(l.opened) >= time.Hour { + l.opened, l.counts = time.Now(), map[string]int{} + } +} + +// allow charges both the account and the address. +func (l *limiter) allow(addr string, v Viewer, perHour int) bool { + ok := l.hit("ip:"+addr, perHour) + if v.SignedIn() { + ok = l.hit(accountKey(v.Name), perHour) && ok + } + return ok +} + +// accountKey is one account's budget, wherever it is spent from. +func accountKey(name string) string { return "account:" + name } |