package server import ( "crypto/sha256" "encoding/hex" "errors" "net/http" "sort" "strings" "time" "cc.tiredsysadmin.keeporg/internal/store" "cc.tiredsysadmin.keeporg/internal/totp" ) // A person's own account: the sessions it holds on each device, and its // second factor. As with administration, every change is a function over the // state taking the caller, and the JSON handlers below and the settings page // in webaccount.go are both thin wrappers over them. var ( // errNoSuchSession is a session that does not exist or is not the // caller's; the two are not told apart. errNoSuchSession = errors.New("no such session") // errNoPassword is an account that signs in only through single // sign-on. Its second factor belongs at the identity provider, and there // is no password to change. errNoPassword = errors.New("this account signs in through single sign-on and has no password here") // errReauth asks for the password and, once enrolled, the one-time code, // before a change to how the account signs in. A live session is what // an unlocked laptop leaves lying around. errReauth = errors.New("confirm with your password and one-time code") ) // sessionID is a session's public name: a one-way digest of its token, so a // page or a response can point at a session without carrying the bearer // token that would sign in as it. func sessionID(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:8]) } // sessionView is one signed-in device as its owner sees it. type sessionView struct { ID string `json:"id"` Label string `json:"label"` Browser bool `json:"browser"` Current bool `json:"current"` CreatedAt time.Time `json:"created_at"` LastSeenAt time.Time `json:"last_seen_at"` ExpiresAt time.Time `json:"expires_at"` } // sessionsOf lists a user's live sessions, the caller's own first and the // rest by most recent use. func sessionsOf(st *store.State, userID, current string) []sessionView { out := []sessionView{} now := time.Now() for token, sess := range st.Sessions { if sess.UserID != userID || now.After(sess.ExpiresAt) { continue } seen := sess.LastSeenAt if seen.IsZero() { seen = sess.CreatedAt } out = append(out, sessionView{ ID: sessionID(token), Label: sess.Label, Browser: sess.IsBrowser(), Current: token == current, CreatedAt: sess.CreatedAt, LastSeenAt: seen, ExpiresAt: sess.ExpiresAt, }) } sort.Slice(out, func(i, j int) bool { if out[i].Current != out[j].Current { return out[i].Current } return out[i].LastSeenAt.After(out[j].LastSeenAt) }) return out } // revokeSession signs one of the caller's sessions out. The device holding // it is refused from its next request on. func revokeSession(st *store.State, userID, id string) error { for token, sess := range st.Sessions { if sess.UserID == userID && sessionID(token) == id { delete(st.Sessions, token) return nil } } return errNoSuchSession } // revokeOtherSessions signs out every session of the caller but keep, and // says how many went. func revokeOtherSessions(st *store.State, userID, keep string) int { n := 0 for token, sess := range st.Sessions { if sess.UserID == userID && token != keep { delete(st.Sessions, token) n++ } } return n } // beginTOTP starts enrolling a second factor and returns its secret. Until // confirmTOTP sees a code from it, the account signs in as before. The // caller has re-proved the password (and any current code): turning a // factor on from a stolen session would lock the owner out, and replacing // one disarms the old. func beginTOTP(st *store.State, userID string) (string, error) { u := st.Users[userID] if u == nil { return "", errNoSuchSession } if u.PassHash == "" { return "", errNoPassword } u.TOTPSecret, u.TOTPConfirmed, u.TOTPLastStep = totp.NewSecret(), false, 0 return u.TOTPSecret, nil } // confirmTOTP turns the second factor on, once a code from it checks out. // That code is spent: it cannot also sign in. func confirmTOTP(st *store.State, userID, code string) error { u := st.Users[userID] if u == nil || u.TOTPSecret == "" { return errors.New("start setting up a second factor first") } if u.TOTPConfirmed { return errors.New("the second factor is already on") } step, ok := totp.Match(u.TOTPSecret, code, time.Now()) if !ok { return errTOTPInvalid } u.TOTPConfirmed, u.TOTPLastStep = true, step return nil } // cancelTOTP abandons an enrolment that was never confirmed. func cancelTOTP(st *store.State, userID string) { if u := st.Users[userID]; u != nil && !u.TOTPConfirmed { u.TOTPSecret = "" } } // disableTOTP turns the second factor off. The caller has re-proved the // password and a current code. func disableTOTP(st *store.State, userID string) error { u := st.Users[userID] if u == nil { return errNoSuchSession } if !u.TOTPConfirmed { return errors.New("no second factor is on") } u.TOTPSecret, u.TOTPConfirmed, u.TOTPLastStep = "", false, 0 return nil } // failAccount maps an account error onto a response. Re-authentication // failures are 403, not 401: a client that drops its token on 401 must not // sign itself out over a mistyped password. func failAccount(w http.ResponseWriter, err error) { if writeThrottled(w, err) { return } switch { case errors.Is(err, errNoSuchSession): fail(w, http.StatusNotFound, "not_found", err.Error()) case errors.Is(err, errReauth), errors.Is(err, errTOTPRequired): fail(w, http.StatusForbidden, "reauth_required", errReauth.Error()) case errors.Is(err, errTOTPInvalid): fail(w, http.StatusForbidden, "totp_invalid", "the one-time code was rejected") case errors.Is(err, errBadCredentials): fail(w, http.StatusForbidden, "bad_credentials", "the password or one-time code is wrong") case errors.Is(err, errNoPassword): fail(w, http.StatusConflict, "no_password", err.Error()) default: fail(w, http.StatusBadRequest, "bad_request", err.Error()) } } // ---- API ---- func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request, u *store.User) { var out []sessionView s.st.Read(func(st *store.State) { out = sessionsOf(st, u.ID, bearer(r)) }) writeJSON(w, http.StatusOK, map[string]any{"sessions": out}) } func (s *Server) handleRevokeSession(w http.ResponseWriter, r *http.Request, u *store.User) { if err := s.st.Update(func(st *store.State) error { return revokeSession(st, u.ID, r.PathValue("id")) }); err != nil { failAccount(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) } func (s *Server) handleRevokeOtherSessions(w http.ResponseWriter, r *http.Request, u *store.User) { var n int if err := s.st.Update(func(st *store.State) error { n = revokeOtherSessions(st, u.ID, bearer(r)) return nil }); err != nil { fail(w, http.StatusInternalServerError, "internal", err.Error()) return } writeJSON(w, http.StatusOK, map[string]int{"revoked": n}) } // reauthRequest is the body of a change that needs re-authentication. type reauthRequest struct { Password string `json:"password"` TOTP string `json:"totp"` } func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request, u *store.User) { var req reauthRequest if r.ContentLength > 0 && !readJSON(w, r, &req) { return } var sec string if err := s.reconfirm(r, u, req.Password, req.TOTP, func(st *store.State, u *store.User) error { var err error sec, err = beginTOTP(st, u.ID) return err }); err != nil { failAccount(w, err) return } writeJSON(w, http.StatusOK, map[string]string{ "secret": sec, "uri": totp.URI("KeepOrg", u.Username, sec), }) } func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request, u *store.User) { var req struct { Code string `json:"code"` } if !readJSON(w, r, &req) { return } if err := s.st.Update(func(st *store.State) error { return confirmTOTP(st, u.ID, req.Code) }); err != nil { if errors.Is(err, errTOTPInvalid) { fail(w, http.StatusBadRequest, "totp_invalid", "code rejected") return } failAccount(w, err) return } writeJSON(w, http.StatusOK, map[string]bool{"totp_enabled": true}) } func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request, u *store.User) { var req reauthRequest if !readJSON(w, r, &req) { return } if err := s.reconfirm(r, u, req.Password, req.TOTP, func(st *store.State, u *store.User) error { return disableTOTP(st, u.ID) }); err != nil { failAccount(w, err) return } writeJSON(w, http.StatusOK, map[string]bool{"totp_enabled": false}) } // browserLabel names a browser session from its User-Agent, as "Firefox on // macOS". It is a label for a person to recognise, not a fingerprint, so a // handful of substrings is enough. func browserLabel(ua string) string { browser := "Browser" for _, b := range []struct{ token, name string }{ {"Edg/", "Edge"}, {"OPR/", "Opera"}, {"Firefox/", "Firefox"}, {"Chrome/", "Chrome"}, {"Safari/", "Safari"}, } { if strings.Contains(ua, b.token) { browser = b.name break } } for _, o := range []struct{ token, name string }{ {"Android", "Android"}, {"iPhone", "iOS"}, {"iPad", "iPadOS"}, {"CrOS", "ChromeOS"}, {"Mac OS X", "macOS"}, {"Windows", "Windows"}, {"Linux", "Linux"}, {"BSD", "BSD"}, } { if strings.Contains(ua, o.token) { return browser + " on " + o.name } } return browser }