aboutsummaryrefslogtreecommitdiff
path: root/auth.go
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commitbb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch)
tree6d5eaf5dac213074cd832da50f64260fce728fed /auth.go
downloadgit-forge-master.tar.gz
Enter git-forgeHEADmaster
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.go510
1 files changed, 510 insertions, 0 deletions
diff --git a/auth.go b/auth.go
new file mode 100644
index 0000000..83f9b10
--- /dev/null
+++ b/auth.go
@@ -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 }