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 // SSOLink starts linking single sign-on to this account, when the // server has a provider and the account is not linked yet. SSOLink string } 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 s.oidc != nil && u.OIDCSubject == "" && !u.Disabled { v.SSOLink = "/oidc/login?link=1&csrf=" + url.QueryEscape(sess.csrf) } 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 // Changes that need the password and code are proven outside the // store's lock, then applied inside it. switch r.FormValue("action") { case "password", "totp-begin", "totp-disable": err := s.accountReconfirmed(r, sess, ¬ice) s.finishAccountAction(w, r, sess, err, notice) return } 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 "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." default: return errBadAction } return nil }) s.finishAccountAction(w, r, sess, err, notice) } // accountReconfirmed applies the settings changes that need the password, // and the one-time code when one is on. func (s *Server) accountReconfirmed(r *http.Request, sess *webSession, notice *string) error { switch r.FormValue("action") { 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 := s.changePassword(r, sess.user, 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 := s.reconfirm(r, sess.user, r.FormValue("password"), r.FormValue("totp"), func(st *store.State, u *store.User) error { _, err := beginTOTP(st, u.ID); return err }); err != nil { return err } *notice = "Add the key below to your authenticator app, then enter the code it shows." case "totp-disable": if err := s.reconfirm(r, sess.user, r.FormValue("password"), r.FormValue("totp"), func(st *store.State, u *store.User) error { return disableTOTP(st, u.ID) }); err != nil { return err } *notice = "Two-factor authentication is off." } return nil } func (s *Server) finishAccountAction(w http.ResponseWriter, r *http.Request, sess *webSession, err error, notice string) { 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 { var throttled *errThrottled switch { case errors.As(err, &throttled): return throttled.Error() 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 or the one-time code 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"}}

Account

How you sign in, and everywhere you are signed in.

{{if .Error}}

{{.Error}}

{{end}} {{if .Notice}}

{{.Notice}}

{{end}} {{with .A}}
You {{if .Email}}{{end}}
Username{{.Username}}
Email{{.Email}}
Signs in with {{if .HasPassword}}password{{end}} {{if .TOTPOn}}one-time code{{end}} {{if .SSO}}single sign-on via {{.SSO}}{{end}} {{if .SSOLink}}
Link single sign-on to this account{{end}}
Role{{if .ServerAdmin}}server administrator{{else}}member{{end}}
Groups{{range $i, $g := .Groups}}{{if $i}}, {{end}}{{$g.Name}} ({{$g.Role}}){{else}}none{{end}}
Member since{{.Since}}
Signed-in devices

Every browser and KeepOrg client signed in as you. Signing one out stops it syncing at once; it has to sign in again.

{{range .Sessions}}{{end}}
DeviceLast activeSigned inExpires
{{if .Label}}{{.Label}}{{else}}unnamed{{end}}
{{if .Browser}}browser{{else}}app{{end}} {{if .Current}}this browser{{end}}
{{.LastSeen}} {{.Created}} {{.Expires}} {{if not .Current}}
{{end}}
{{if gt (len .Sessions) 1}}
{{end}}
{{if .HasPassword}}
Two-factor authentication {{if .TOTPOn}}

On. Signing in with your password also asks for a one-time code from your authenticator app.

{{else if .TOTPKey}}

Add this key to an authenticator app such as Aegis, 2FAS or Google Authenticator, then enter the six-digit code it shows.

{{.TOTPKey}}

Open in an authenticator app on this device

{{else}}

Off. With it on, a stolen password is not enough to sign in: each sign-in also needs a code from an app on your phone.

{{end}}
Password

The password you sign in with, not any vault's password. Changing it signs out your other devices.

{{if .TOTPOn}} {{end}}
{{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}}`))