aboutsummaryrefslogtreecommitdiff
path: root/account.go
diff options
context:
space:
mode:
Diffstat (limited to 'account.go')
-rw-r--r--account.go695
1 files changed, 695 insertions, 0 deletions
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)
+}