package server import ( "errors" "fmt" "html/template" "net/http" "net/url" "sort" "strings" "time" "cc.tiredsysadmin.keeporg/internal/store" "cc.tiredsysadmin.keeporg/internal/totp" ) // The settings page at /web/account: who you are, how you sign in, and every // device and browser signed in as you, each of which can be signed out from // here. The rules are the functions in account.go, shared with the API. // webAccount is what the settings page shows. type webAccount struct { Username string Email string ServerAdmin bool Since string Groups []webMembership HasPassword bool SSO string // the identity provider's host, when linked TOTPOn bool // TOTPKey and TOTPLink are set while an enrolment awaits its first code. TOTPKey string TOTPLink template.URL Sessions []webSessionRow } type webMembership struct { Name string Role string } type webSessionRow struct { ID string Label string Browser bool Current bool Created string LastSeen string Expires string } func (s *Server) accountView(sess *webSession) webAccount { var v webAccount s.st.Read(func(st *store.State) { u := st.Users[sess.user.ID] if u == nil { return } v = webAccount{ Username: u.Username, Email: u.Email, ServerAdmin: u.ServerAdmin, Since: u.CreatedAt.UTC().Format("2006-01-02"), HasPassword: u.PassHash != "", TOTPOn: u.TOTPConfirmed, } if u.OIDCIssuer != "" { v.SSO = u.OIDCIssuer if iss, err := url.Parse(u.OIDCIssuer); err == nil && iss.Host != "" { v.SSO = iss.Host } } if u.TOTPSecret != "" && !u.TOTPConfirmed { v.TOTPKey = groupKey(u.TOTPSecret) // The otpauth: scheme is what authenticator apps register for; // html/template would neutralise it, and the URI is built here // from our own values. v.TOTPLink = template.URL(totp.URI("KeepOrg", u.Username, u.TOTPSecret)) } for _, m := range st.Memberships { if m.UserID != u.ID { continue } if g := st.Groups[m.GroupID]; g != nil { v.Groups = append(v.Groups, webMembership{Name: g.Name, Role: string(m.Role)}) } } sort.Slice(v.Groups, func(i, j int) bool { return v.Groups[i].Name < v.Groups[j].Name }) for _, sv := range sessionsOf(st, u.ID, sess.token) { v.Sessions = append(v.Sessions, webSessionRow{ ID: sv.ID, Label: sv.Label, Browser: sv.Browser, Current: sv.Current, Created: sv.CreatedAt.UTC().Format("2006-01-02 15:04 UTC"), LastSeen: ago(sv.LastSeenAt), Expires: sv.ExpiresAt.UTC().Format("2006-01-02"), }) } }) return v } // groupKey spaces a base32 secret in fours, for typing into an app by hand. func groupKey(k string) string { var b strings.Builder for i, r := range k { if i > 0 && i%4 == 0 { b.WriteByte(' ') } b.WriteRune(r) } return b.String() } // ago says how long ago t was, coarsely. func ago(t time.Time) string { d := time.Since(t) switch { case d < 2*time.Minute: return "just now" case d < time.Hour: return fmt.Sprintf("%d minutes ago", int(d.Minutes())) case d < 2*time.Hour: return "an hour ago" case d < 24*time.Hour: return fmt.Sprintf("%d hours ago", int(d.Hours())) case d < 48*time.Hour: return "yesterday" case d < 30*24*time.Hour: return fmt.Sprintf("%d days ago", int(d.Hours()/24)) } return t.UTC().Format("2006-01-02") } func (s *Server) handleWebAccount(w http.ResponseWriter, r *http.Request, sess *webSession) { s.renderAccount(w, sess, "", "") } // handleWebAccountAction applies one control of the settings page. func (s *Server) handleWebAccountAction(w http.ResponseWriter, r *http.Request, sess *webSession) { var notice string err := s.st.Update(func(st *store.State) error { switch r.FormValue("action") { case "revoke": if err := revokeSession(st, sess.user.ID, r.FormValue("session")); err != nil { return err } notice = "That device is signed out. It has to sign in again to sync." case "revoke-others": n := revokeOtherSessions(st, sess.user.ID, sess.token) notice = fmt.Sprintf("Signed out %d other %s.", n, plural(n, "session", "sessions")) case "password": next := r.FormValue("new_password") if next != r.FormValue("new_password2") { return errors.New("the two new passwords do not match") } if err := changePassword(st, sess.user.Username, r.FormValue("current_password"), r.FormValue("totp"), next, sess.token); err != nil { return err } notice = "Password changed. Your other sessions were signed out." case "totp-begin": if _, err := beginTOTP(st, sess.user.ID, r.FormValue("password"), r.FormValue("totp")); err != nil { return err } notice = "Add the key below to your authenticator app, then enter the code it shows." case "totp-confirm": if err := confirmTOTP(st, sess.user.ID, r.FormValue("code")); err != nil { return err } notice = "Two-factor authentication is on. Signing in now asks for a one-time code." case "totp-cancel": cancelTOTP(st, sess.user.ID) notice = "Setup cancelled. Nothing changed." case "totp-disable": if err := disableTOTP(st, sess.user.ID, r.FormValue("password"), r.FormValue("totp")); err != nil { return err } notice = "Two-factor authentication is off." default: return errBadAction } return nil }) if err != nil { s.renderAccount(w, sess, accountError(err), "") return } // Signing out one's own browser from the list ends this session too. if s.webUser(r) == nil { s.setWebCookie(w, "", -time.Second) http.Redirect(w, r, "/web", http.StatusFound) return } s.renderAccount(w, sess, "", notice) } func accountError(err error) string { switch { case errors.Is(err, errReauth), errors.Is(err, errTOTPRequired): return "Confirm with your password and your current one-time code." case errors.Is(err, errTOTPInvalid): return "That one-time code was rejected. Check the clock on the device that shows it." case errors.Is(err, errBadCredentials): return "The password is wrong." case errors.Is(err, errNoSuchSession): return "That session is already signed out." } return humanError(err) } func plural(n int, one, many string) string { if n == 1 { return one } return many } func (s *Server) renderAccount(w http.ResponseWriter, sess *webSession, errMsg, notice string) { d := s.shell(sess, "Account", "Account") d["A"], d["Error"], d["Notice"] = s.accountView(sess), errMsg, notice render(w, accountTmpl, d) } var accountTmpl = template.Must(template.Must(pages.Clone()).Parse(` {{define "body"}}
How you sign in, and everywhere you are signed in.
{{if .Error}}{{.Error}}
{{end}} {{if .Notice}}{{.Notice}}
{{end}} {{with .A}} {{if .HasPassword}} {{else}}You sign in through single sign-on{{if .SSO}} at {{.SSO}}{{end}}, so your password and second factor are managed there, not here.
{{end}} {{end}} {{end}}`))