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) }