From bb6bc2387d58c737dbf92e73f8544bf39319dbd8 Mon Sep 17 00:00:00 2001 From: Lena Date: Sat, 1 Aug 2026 00:00:00 +0000 Subject: Enter git-forge 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. --- account.go | 695 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 695 insertions(+) create mode 100644 account.go (limited to 'account.go') diff --git a/account.go b/account.go new file mode 100644 index 0000000..2cb15c1 --- /dev/null +++ b/account.go @@ -0,0 +1,695 @@ +package main + +// The account's own pages: signing in and out, settings, passwords, addresses, +// push tokens, and the second factor. + +import ( + "crypto/hmac" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" +) + +// loginPage lists the external providers by key and label alone: the template +// is handed nothing it must not print. +type loginPage struct{ Providers []loginLink } + +type loginLink struct{ Key, Label string } + +func (f *Forge) loginData() loginPage { + var links []loginLink + for _, p := range f.cfg.OIDC { + links = append(links, loginLink{Key: p.Key, Label: p.Label}) + } + return loginPage{Providers: links} +} + +func (f *Forge) handleLogin(w http.ResponseWriter, r *http.Request) { + if _, _, ok := f.user(r); ok { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + d := f.page(r) + d.Data = f.loginData() + f.render(w, "login.html", http.StatusOK, d) +} + +func (f *Forge) handleLoginPost(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + // Failures only, per address only: per account, anybody could lock a + // victim out. + if f.loginLimit.over("login:"+f.peer(r), loginsPerHour) { + f.fail(w, r, http.StatusTooManyRequests, "too many failed sign in attempts from here, try again later") + return + } + name := accountName(r.PostFormValue("name")) + password := r.PostFormValue("password") + + u, ok := f.store.User(name) + if !ok || u.Origin != originLocal { + u = User{} // hash anyway, so the two failures cost the same + } + if !passwordOK(u.Password, password) { + f.loginLimit.charge("login:" + f.peer(r)) + log.Printf("login refused for %q from %s", name, f.peer(r)) + d := f.page(r) + d.Error = "wrong account name or password" + d.Data = f.loginData() + f.render(w, "login.html", http.StatusUnauthorized, d) + return + } + if u.TOTP != nil { + f.askForCode(w, r, name, u, "") + return + } + f.setSession(w, name, u.Epoch) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +type totpPage struct{ Name, Expires, Token string } + +// askForCode is the second step of a login. The half-finished login is carried +// as the account, a short expiry, and a MAC over both plus the epoch, which an +// external account has where it has no password. +func (f *Forge) askForCode(w http.ResponseWriter, r *http.Request, name string, u User, message string) { + expires := strconv.FormatInt(time.Now().Add(totpPendLife).Unix(), 10) + d := f.page(r) + d.Error = message + d.Data = totpPage{ + Name: name, Expires: expires, + Token: f.mac("totp-pending", name, expires, strconv.Itoa(u.Epoch)), + } + status := http.StatusOK + if message != "" { + status = http.StatusUnauthorized + } + f.render(w, "totp.html", status, d) +} + +func (f *Forge) handleLoginTOTP(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, expires := r.PostFormValue("name"), r.PostFormValue("expires") + unix, err := strconv.ParseInt(expires, 10, 64) + u, ok := f.store.User(name) + if err != nil || !ok || u.TOTP == nil || time.Now().After(time.Unix(unix, 0)) { + f.fail(w, r, http.StatusForbidden, "that login took too long. sign in again") + return + } + if !hmac.Equal([]byte(r.PostFormValue("t")), []byte(f.mac("totp-pending", name, expires, strconv.Itoa(u.Epoch)))) { + f.fail(w, r, http.StatusForbidden, "that login did not start here. sign in again") + return + } + // Six digits is not many. + if !f.totpLimit.allow(f.peer(r), Viewer{Name: name}, totpPerHour) { + f.fail(w, r, http.StatusTooManyRequests, "too many codes tried in the last hour, try again later") + return + } + + code := strings.TrimSpace(r.PostFormValue("code")) + switch { + case f.codeOK(name, u, code): + case f.spendRecoveryCode(name, u, code): + f.mailAccount(u, "a recovery code was used", + "A recovery code was used to sign in to "+name+" on "+f.siteName()+ + ". It cannot be used again.\n") + u, _ = f.store.User(name) + default: + f.askForCode(w, r, name, u, "that code is not right") + return + } + f.setSession(w, name, u.Epoch) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// spendRecoveryCode removes the code in the same write that accepts it, and +// decides inside that write, so two logins with one code cannot both succeed. +func (f *Forge) spendRecoveryCode(name string, u User, code string) bool { + want := tokenHash(strings.TrimSpace(code)) + err := f.store.UpdateUser(name, "users: spend a recovery code for "+name, f.store.author(name, u), + func(acct *User) error { + if acct.TOTP == nil { + return errBadCode + } + left := []string{} + for _, kept := range acct.TOTP.Recovery { + if !hmac.Equal([]byte(kept), []byte(want)) { + left = append(left, kept) + } + } + if len(left) == len(acct.TOTP.Recovery) { + return errBadCode + } + acct.TOTP.Recovery = left + return nil + }) + if err != nil && !errors.Is(err, errBadCode) { + log.Printf("totp: %v", err) + } + return err == nil +} + +type enrollPage struct { + Secret string // only when there is nothing enrolled yet + URI string // the same secret, as an otpauth link + Enrolled bool + Required bool // an admin may not turn it off + Local bool // an external account has no password to ask for + Left int // recovery codes still unspent + Codes []string // shown once, right after they are made +} + +// The proposed secret lives in the form until a code proves the authenticator +// has it. +func (f *Forge) handleTOTP(w http.ResponseWriter, r *http.Request) { + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + d := f.page(r) + d.Data = f.enrollData(name, u, "", nil) + f.render(w, "totp-enroll.html", http.StatusOK, d) +} + +// secret is the one being enrolled: empty mints a fresh one, and a retry keeps +// the one already typed into the authenticator. +func (f *Forge) enrollData(name string, u User, secret string, codes []string) enrollPage { + page := enrollPage{Required: u.Admin, Codes: codes, Local: u.Origin == originLocal} + if u.TOTP != nil { + page.Enrolled, page.Left = true, len(u.TOTP.Recovery) + return page + } + if secret == "" { + secret = totpSecret() + } + page.Secret, page.URI = secret, f.totpURI(name, secret) + return page +} + +// proveOwner stands between a stolen cookie and a change that outlives the +// session: a local account is asked for its password, an external one for a +// code, and one with neither has nothing to be asked for. +func (f *Forge) proveOwner(w http.ResponseWriter, r *http.Request, name string, u User, secret string) bool { + if u.Origin != originLocal { + return u.TOTP == nil || f.confirmCode(w, r) + } + if passwordOK(u.Password, r.PostFormValue("current")) { + return true + } + f.enrollError(w, r, name, u, secret, "the current password is wrong", http.StatusUnauthorized) + return false +} + +// enrollError draws the authenticator page again with a message and the same +// secret. +func (f *Forge) enrollError(w http.ResponseWriter, r *http.Request, name string, u User, secret, msg string, status int) { + d := f.page(r) + d.Error = msg + d.Data = f.enrollData(name, u, secret, nil) + f.render(w, "totp-enroll.html", status, d) +} + +func (f *Forge) handleTOTPEnroll(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + secret := strings.TrimSpace(r.PostFormValue("secret")) + if u.TOTP != nil { + f.fail(w, r, http.StatusConflict, "this account already has an authenticator. remove it first") + return + } + if !validSecret(secret) { + f.fail(w, r, http.StatusBadRequest, "that is not a secret this forge minted. start again from the settings page") + return + } + // Without this a stolen cookie takes the account for good. + if !f.proveOwner(w, r, name, u, secret) { + return + } + step := totpStep(secret, r.PostFormValue("code"), time.Now()) + if step < 0 { + f.enrollError(w, r, name, u, secret, + "that code is not right. check the clock on the device, and try the next code", + http.StatusBadRequest) + return + } + + show, keep := newRecoveryCodes() + err := f.store.UpdateUser(name, "users: enroll an authenticator for "+name, f.store.author(name, u), + func(acct *User) error { + // The enrolling code is spent too. + acct.TOTP = &TOTP{Secret: secret, Recovery: keep, Used: step} + acct.Epoch++ // every other session dies with the change + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + u, _ = f.store.User(name) + f.mailAccount(u, "an authenticator was enrolled", + "An authenticator was enrolled for "+name+" on "+f.siteName()+ + ", and every other session was signed out.\n") + + d := f.pageFor(name, u, f.setSession(w, name, u.Epoch)) + d.Notice = "enrolled. write these recovery codes down now: each one works once, and they are not shown again" + d.Data = f.enrollData(name, u, "", show) + f.render(w, "totp-enroll.html", http.StatusOK, d) +} + +func (f *Forge) handleTOTPRemove(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + if u.Admin { + f.fail(w, r, http.StatusForbidden, + "an account with the global admin flag must keep an authenticator. have another admin take the flag away first") + return + } + if !f.proveOwner(w, r, name, u, "") { + return + } + err := f.store.UpdateUser(name, "users: remove the authenticator for "+name, f.store.author(name, u), + func(acct *User) error { + acct.TOTP = nil + acct.Epoch++ + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + u, _ = f.store.User(name) + f.mailAccount(u, "the authenticator was removed", + "The authenticator for "+name+" on "+f.siteName()+ + " was removed, and every other session was signed out.\n") + f.setSession(w, name, u.Epoch) + http.Redirect(w, r, "/settings/totp", http.StatusSeeOther) +} + +func (f *Forge) handleTOTPCodes(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + if u.TOTP == nil { + f.fail(w, r, http.StatusConflict, "this account has no authenticator") + return + } + // New codes are new keys to the account, so they cost a proof too. + if !f.proveOwner(w, r, name, u, "") { + return + } + show, keep := newRecoveryCodes() + err := f.store.UpdateUser(name, "users: regenerate recovery codes for "+name, f.store.author(name, u), + func(acct *User) error { + // An admin can clear it between the check above and this write. + if acct.TOTP == nil { + return userError("this account has no authenticator") + } + acct.TOTP.Recovery = keep + return nil + }) + if err != nil { + f.problem(w, r, http.StatusConflict, err) + return + } + u, _ = f.store.User(name) + f.mailAccount(u, "recovery codes were regenerated", + "New recovery codes were made for "+name+" on "+f.siteName()+". The old ones no longer work.\n") + + d := f.page(r) + d.Notice = "write these down now: each one works once, and they are not shown again" + d.Data = f.enrollData(name, u, "", show) + f.render(w, "totp-enroll.html", http.StatusOK, d) +} + +func (f *Forge) handleLogout(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + f.clearSession(w) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// settingsPage is the account's own record, for its own page. +type settingsPage struct { + Email string + EmailConfirmed bool + Notify bool + Local bool // an external account has no password to change here + Tokens []Token + Secret string // a push token just minted, shown once +} + +func settingsFor(u User, d pageData) pageData { + d.Data = settingsPage{ + Email: u.Email, EmailConfirmed: u.EmailConfirmed, + Notify: u.Notify, Local: u.Origin == originLocal, Tokens: u.Tokens, + } + return d +} + +func (f *Forge) handleSettings(w http.ResponseWriter, r *http.Request) { + _, u, ok := f.requireUser(w, r) + if !ok { + return + } + f.render(w, "settings.html", http.StatusOK, settingsFor(u, f.page(r))) +} + +// The current password is required: a stolen cookie must not be enough. +func (f *Forge) handlePassword(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + + current := r.PostFormValue("current") + next := r.PostFormValue("new") + again := r.PostFormValue("again") + + d := f.page(r) + switch { + case u.Origin != originLocal: + d.Error = "this account signs in through an external provider" + case !passwordOK(u.Password, current): + d.Error = "the current password is wrong" + case next != again: + d.Error = "the two new passwords are not the same" + case len(next) < minPassword: + d.Error = fmt.Sprintf("the new password must be at least %d characters", minPassword) + } + if d.Error != "" { + f.render(w, "settings.html", http.StatusBadRequest, settingsFor(u, d)) + return + } + + hash := hashPassword(next) + err := f.store.UpdateUser(name, "users: change password for "+name, f.store.author(name, u), + func(acct *User) error { + acct.Password = hash + acct.Epoch++ // every other session for this account dies here + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + + // The bump killed this browser's cookie too; reissue it. + u, _ = f.store.User(name) + f.mailAccount(u, "your password changed", + "The password for "+name+" on "+f.siteName()+" was just changed, and every other session was signed out.\n\n"+ + "If that was not you, ask an admin for a new set-password link.\n") + d = f.pageFor(name, u, f.setSession(w, name, u.Epoch)) + d.Notice = "password changed, and every other session was signed out" + f.render(w, "settings.html", http.StatusOK, settingsFor(u, d)) +} + +type setPasswordPage struct{ Name, Expires, Token string } + +func (f *Forge) handleSetPassword(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if _, _, ok := f.checkSetPassword(q); !ok { + f.fail(w, r, http.StatusNotFound, "this link has expired or has already been used") + return + } + d := f.page(r) + d.Data = setPasswordPage{Name: q.Get("name"), Expires: q.Get("expires"), Token: q.Get("t")} + f.render(w, "set-password.html", http.StatusOK, d) +} + +func (f *Forge) handleSetPasswordPost(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.checkSetPassword(r.PostForm) + if !ok { + f.fail(w, r, http.StatusNotFound, "this link has expired or has already been used") + return + } + + next, again := r.PostFormValue("new"), r.PostFormValue("again") + d := f.page(r) + d.Data = setPasswordPage{Name: name, Expires: r.PostFormValue("expires"), Token: r.PostFormValue("t")} + switch { + case next != again: + d.Error = "the two passwords are not the same" + case len(next) < minPassword: + d.Error = fmt.Sprintf("the password must be at least %d characters", minPassword) + } + if d.Error != "" { + f.render(w, "set-password.html", http.StatusBadRequest, d) + return + } + + hash := hashPassword(next) + err := f.store.UpdateUser(name, "users: set password for "+name, f.store.author(name, u), + func(acct *User) error { + acct.Password = hash + acct.Epoch++ + // Following the link is what proves the address, when there is one. + acct.EmailConfirmed = acct.EmailConfirmed || acct.Email != "" + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + u, _ = f.store.User(name) + f.mailAccount(u, "your password was set", + "A password for "+name+" on "+f.siteName()+" was just set through a one time link.\n") + f.setSession(w, name, u.Epoch) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// Reading is what allows minting: a token is a credential, not a permission, +// and what it may do is decided on arrival. +func (f *Forge) handleTokenMint(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + v := f.viewer(r) + path := strings.Trim(strings.TrimSpace(r.PostFormValue("repo")), "/") + repo, exists := f.store.Repo(path) + if !exists || !v.CanRead(repo) { + d := settingsFor(u, f.page(r)) + d.Error = "there is no repository called " + path + " that you can reach" + f.render(w, "settings.html", http.StatusForbidden, d) + return + } + // Every mint is a commit, and every account can reach every public repo. + if !f.tokenLimit.allow(f.peer(r), v, tokensPerHour) { + f.fail(w, r, http.StatusTooManyRequests, "too many tokens minted in the last hour, try again later") + return + } + + secret, token := mintToken(path) + err := f.store.UpdateUser(name, "users: mint a push token for "+name+" on "+path, f.store.author(name, u), + func(acct *User) error { + // Under the lock, so two at once cannot both find room. + if len(acct.Tokens) >= maxTokens { + return userError(fmt.Sprintf( + "this account already holds %d tokens, which is all it may hold. revoke one first", maxTokens)) + } + acct.Tokens = append(append([]Token(nil), acct.Tokens...), token) + return nil + }) + if err != nil { + f.problem(w, r, http.StatusConflict, err) + return + } + u, _ = f.store.User(name) + f.mailAccount(u, "a push token was minted", + "A push token for "+path+" was minted for "+name+" on "+f.siteName()+ + ", with the id "+token.ID+".\n\nIf that was not you, revoke it at "+f.url("/settings")+"\n") + + d := settingsFor(u, f.page(r)) + d.Notice = "copy this token now: it is the password for git over http, it carries your own " + + "access to " + path + " and nothing more, and it is not shown again" + page := d.Data.(settingsPage) + page.Secret = secret + d.Data = page + f.render(w, "settings.html", http.StatusOK, d) +} + +func (f *Forge) handleTokenRevoke(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + id := r.PostFormValue("id") + var revoked Token + err := f.store.UpdateUser(name, "users: revoke a push token for "+name, f.store.author(name, u), + func(acct *User) error { + left := []Token{} + for _, t := range acct.Tokens { + if t.ID == id { + revoked = t + continue + } + left = append(left, t) + } + if revoked.ID == "" { + return userError("no such token") + } + acct.Tokens = left + return nil + }) + if err != nil { + f.problem(w, r, http.StatusNotFound, err) + return + } + u, _ = f.store.User(name) + f.mailAccount(u, "a push token was revoked", + "The push token "+revoked.ID+" for "+revoked.Repo+" on "+f.siteName()+" was revoked.\n") + http.Redirect(w, r, "/settings", http.StatusSeeOther) +} + +func (f *Forge) handleConfirmEmail(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + name, expires := q.Get("name"), q.Get("expires") + unix, err := strconv.ParseInt(expires, 10, 64) + u, ok := f.store.User(name) + if err != nil || !ok || time.Now().After(time.Unix(unix, 0)) { + f.fail(w, r, http.StatusNotFound, "this link has expired or has already been used") + return + } + if !hmac.Equal([]byte(q.Get("t")), []byte(f.mac("confirm-email", name, expires, u.Email))) { + f.fail(w, r, http.StatusNotFound, "this link has expired or has already been used") + return + } + err = f.store.UpdateUser(name, "users: confirm the address of "+name, f.store.author(name, u), + func(acct *User) error { + acct.EmailConfirmed = true + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + d := f.page(r) + d.Notice = "that address is confirmed, and will now receive notifications" + f.render(w, "home.html", http.StatusOK, f.withIndex(r, d)) +} + +// A new address is unconfirmed until a link sent to it is followed, and an +// unconfirmed address receives nothing else. +func (f *Forge) handleEmail(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + email := strings.TrimSpace(r.PostFormValue("email")) + notify := r.PostFormValue("notify") != "" + if !validEmail(email) { + d := f.page(r) + d.Error = "that does not look like an email address" + f.render(w, "settings.html", http.StatusBadRequest, settingsFor(u, d)) + return + } + + changed := email != u.Email + err := f.store.UpdateUser(name, "users: set the address and notifications of "+name, f.store.author(name, u), + func(acct *User) error { + acct.Notify = notify + if changed { + acct.Email, acct.EmailConfirmed = email, false + } + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + + u, _ = f.store.User(name) + d := settingsFor(u, f.page(r)) + d.Notice = "saved" + // An address still unproved gets another link. + if email != "" && (changed || !u.EmailConfirmed) { + f.mailLink(email, "confirm this address", + "Follow this link to confirm this address for "+name+":\n\n"+f.confirmEmailLink(name, u)+"\n") + d.Notice = "saved, and a link to confirm the address is on its way to it" + } + f.render(w, "settings.html", http.StatusOK, d) +} + +// The same sentence whether or not the account exists. +func (f *Forge) handleReset(w http.ResponseWriter, r *http.Request) { + f.render(w, "reset.html", http.StatusOK, f.page(r)) +} + +func (f *Forge) handleResetPost(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + d := f.page(r) + d.Notice = "if that account exists and has an address on file, a link to set a new password is on its way to it" + + if !f.resetLimit.allow(f.peer(r), f.viewer(r), resetsPerHour) { + f.fail(w, r, http.StatusTooManyRequests, "too many reset requests in the last hour, try again later") + return + } + name := accountName(r.PostFormValue("name")) + if u, ok := f.store.User(name); ok && u.Origin == originLocal && u.Email != "" { + f.mailLink(u.Email, "set a new password", + "Somebody asked for a new password for "+name+". Follow this link within an hour to set one:\n\n"+ + f.passwordLink(name, u, resetLife)+"\n\nIf that was not you, nothing has happened and you can ignore this.\n") + } + f.render(w, "reset.html", http.StatusOK, d) +} + +// Without a session store, signing out everywhere is an epoch bump. +func (f *Forge) handleSignOut(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + err := f.store.UpdateUser(name, "users: sign out every session for "+name, f.store.author(name, u), + func(acct *User) error { + acct.Epoch++ + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + f.clearSession(w) + http.Redirect(w, r, "/", http.StatusSeeOther) +} -- cgit v1.2.3