package server import ( "errors" "fmt" "net/http" "net/url" "strings" "time" "cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/store" ) // MinPasswordLen is the shortest password the server will store. const MinPasswordLen = 10 // deviceTTL bounds how long a pending device login stays open. const deviceTTL = 10 * time.Minute // maxPendingDevices bounds the device logins anyone can open without an // account. Each is a row in the state, which every write copies. const maxPendingDevices = 500 // errBusy means too many device logins are pending. var errBusy = errors.New("too many sign-ins are pending; try again in a few minutes") // pruneDevices drops expired device logins and reports whether there is // room for another. func pruneDevices(st *store.State) bool { now := time.Now() for k, d := range st.Devices { if now.After(d.ExpiresAt) { delete(st.Devices, k) } } return len(st.Devices) < maxPendingDevices } var ( errBadCredentials = errors.New("bad credentials") errTOTPRequired = errors.New("totp required") errTOTPInvalid = errors.New("totp invalid") ) type loginRequest struct { Username string `json:"username"` Password string `json:"password"` TOTP string `json:"totp"` Label string `json:"label"` } type loginResponse struct { Token string `json:"token"` ExpiresAt time.Time `json:"expires_at"` User userView `json:"user"` } type userView struct { ID string `json:"id"` Username string `json:"username"` Email string `json:"email,omitempty"` ServerAdmin bool `json:"server_admin"` TOTPEnabled bool `json:"totp_enabled"` } func view(u *store.User) userView { return userView{ID: u.ID, Username: u.Username, Email: u.Email, ServerAdmin: u.ServerAdmin, TOTPEnabled: u.TOTPConfirmed} } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { var req loginRequest if !readJSON(w, r, &req) { return } var resp loginResponse err := s.signIn(r, req.Username, req.Password, req.TOTP, func(st *store.State, u *store.User) error { sess := s.newSession(st, u.ID, clampLabel(req.Label)) resp = loginResponse{Token: sess.Token, ExpiresAt: sess.ExpiresAt, User: view(u)} return nil }) if writeThrottled(w, err) { return } switch { case errors.Is(err, errTOTPRequired): fail(w, http.StatusUnauthorized, "totp_required", "second factor required") case errors.Is(err, errTOTPInvalid): fail(w, http.StatusUnauthorized, "totp_invalid", "second factor rejected") case errors.Is(err, errBadCredentials): fail(w, http.StatusUnauthorized, "bad_credentials", "unknown user or wrong password") case err != nil: fail(w, http.StatusInternalServerError, "internal", err.Error()) default: writeJSON(w, http.StatusOK, resp) } } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, _ *store.User) { tok := bearer(r) if err := s.st.Update(func(st *store.State) error { delete(st.Sessions, tok) return nil }); err != nil { fail(w, http.StatusInternalServerError, "internal", err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"}) } func (s *Server) handleWhoami(w http.ResponseWriter, r *http.Request, u *store.User) { type groupView struct { ID string `json:"id"` Name string `json:"name"` Role store.Role `json:"role"` } out := struct { userView Groups []groupView `json:"groups"` }{userView: view(u), Groups: []groupView{}} s.st.Read(func(st *store.State) { for _, m := range st.Memberships { if m.UserID != u.ID { continue } if g := st.Groups[m.GroupID]; g != nil { out.Groups = append(out.Groups, groupView{ID: g.ID, Name: g.Name, Role: m.Role}) } } }) writeJSON(w, http.StatusOK, out) } // ---- device login: the browser approves, the client polls ---- func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) { var req struct { Label string `json:"label"` ReturnURL string `json:"return_url"` } if r.ContentLength > 0 && !readJSON(w, r, &req) { return } if req.ReturnURL != "" && !validReturnURL(req.ReturnURL) { fail(w, http.StatusBadRequest, "bad_request", "return_url must use the "+returnScheme+": scheme") return } if wait := s.limits.take("start:" + s.clientIP(r)); wait > 0 { writeThrottled(w, &errThrottled{RetryAfter: wait}) return } d := &store.DeviceAuth{ DeviceCode: secret.Token(), UserCode: secret.UserCode(), Label: clampLabel(req.Label), ReturnURL: req.ReturnURL, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(deviceTTL), } if err := s.st.Update(func(st *store.State) error { if !pruneDevices(st) { return errBusy } st.Devices[d.DeviceCode] = d return nil }); err != nil { if errors.Is(err, errBusy) { fail(w, http.StatusServiceUnavailable, "busy", err.Error()) return } s.log.Printf("device start: %v", err) fail(w, http.StatusInternalServerError, "internal", "could not start the sign-in") return } writeJSON(w, http.StatusOK, map[string]any{ "device_code": d.DeviceCode, "user_code": d.UserCode, "verification_url": s.cfg.ExternalURL + "/device?code=" + d.UserCode, "interval": 2, "expires_in": int(deviceTTL.Seconds()), }) } func (s *Server) handleDevicePoll(w http.ResponseWriter, r *http.Request) { var req struct { DeviceCode string `json:"device_code"` } if !readJSON(w, r, &req) { return } var ( token string userID string gone bool ) err := s.st.Update(func(st *store.State) error { d := st.Devices[req.DeviceCode] if d == nil || time.Now().After(d.ExpiresAt) { delete(st.Devices, req.DeviceCode) gone = true return nil } if d.SessionToken == "" { return nil } token = d.SessionToken if sess := st.Sessions[token]; sess != nil { userID = sess.UserID } delete(st.Devices, req.DeviceCode) return nil }) switch { case err != nil: fail(w, http.StatusInternalServerError, "internal", err.Error()) case gone: fail(w, http.StatusGone, "expired", "device code expired") case token == "": writeJSON(w, http.StatusAccepted, map[string]string{"status": "pending"}) default: var resp loginResponse s.st.Read(func(st *store.State) { resp.Token = token if sess := st.Sessions[token]; sess != nil { resp.ExpiresAt = sess.ExpiresAt } if u := st.Users[userID]; u != nil { resp.User = view(u) } }) writeJSON(w, http.StatusOK, resp) } } func (s *Server) handleDevicePage(w http.ResponseWriter, r *http.Request) { s.renderDevice(w, r.URL.Query().Get("code"), "") } func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { fail(w, http.StatusBadRequest, "bad_request", err.Error()) return } code := strings.ToUpper(strings.TrimSpace(r.FormValue("code"))) var returnURL string err := s.signIn(r, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp"), func(st *store.State, u *store.User) error { d := st.DeviceByUserCode(code) if d == nil || time.Now().After(d.ExpiresAt) || d.SessionToken != "" { return errors.New("unknown or expired code") } returnURL = d.ReturnURL d.SessionToken = s.newSession(st, u.ID, d.Label).Token return nil }) var throttled *errThrottled switch { case errors.As(err, &throttled): s.renderDevice(w, code, throttled.Error()) case errors.Is(err, errTOTPRequired): s.renderDevice(w, code, "This account requires a one-time code.") case errors.Is(err, errTOTPInvalid): s.renderDevice(w, code, "That one-time code was rejected.") case errors.Is(err, errBadCredentials): s.renderDevice(w, code, "Unknown user or wrong password.") case err != nil: s.renderDevice(w, code, err.Error()) default: s.renderApproved(w, returnURL) } } // returnScheme is the only scheme a device may ask to be sent back to. It // names KeepOrg's own clients, so the approval page cannot be turned into a // redirect to anywhere else. const returnScheme = "keeporg" func validReturnURL(raw string) bool { u, err := url.Parse(raw) return err == nil && u.Scheme == returnScheme && u.User == nil } // changePassword replaces a user's password after proving the current one. // It is the one place a password changes, so the rules live here: the // current password is required even though the caller already holds a // session, the second factor is required when one is enrolled, and every // other session of that user is revoked, because a password change is what // someone does when they think a session is no longer theirs. func (s *Server) changePassword(r *http.Request, u *store.User, current, code, next, keepToken string) error { if len(next) < MinPasswordLen { return fmt.Errorf("the new password must be at least %d characters", MinPasswordLen) } if next == current { return errors.New("the new password is the same as the old one") } // Hashed before the write, for the same reason verify checks outside it. hash, err := secret.Hash(next) if err != nil { return err } return s.reconfirm(r, u, current, code, func(st *store.State, u *store.User) error { u.PassHash = hash for token, sess := range st.Sessions { if sess.UserID == u.ID && token != keepToken { delete(st.Sessions, token) } } return nil }) } func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request, u *store.User) { var req struct { CurrentPassword string `json:"current_password"` NewPassword string `json:"new_password"` TOTP string `json:"totp"` } if !readJSON(w, r, &req) { return } err := s.changePassword(r, u, req.CurrentPassword, req.TOTP, req.NewPassword, bearer(r)) if writeThrottled(w, err) { return } switch { case errors.Is(err, errTOTPRequired): fail(w, http.StatusUnauthorized, "totp_required", "second factor required") case errors.Is(err, errBadCredentials), errors.Is(err, errReauth): fail(w, http.StatusUnauthorized, "bad_credentials", "the current password or one-time code is wrong") case errors.Is(err, errNoPassword): fail(w, http.StatusUnauthorized, "bad_credentials", err.Error()) case err != nil: fail(w, http.StatusBadRequest, "bad_request", err.Error()) default: writeJSON(w, http.StatusOK, map[string]string{ "status": "password changed; other sessions on this account were signed out", }) } } // ---- invites ---- func (s *Server) handleInvitePage(w http.ResponseWriter, r *http.Request) { tok := r.PathValue("token") var ok bool s.st.Read(func(st *store.State) { if inv := st.Invites[tok]; inv != nil && inv.Usable(time.Now()) { ok = true } }) if !ok { s.renderDone(w, "Invite not valid", "This invite link has expired or has already been used.") return } s.renderInvite(w, tok, "") } func (s *Server) handleInviteRedeem(w http.ResponseWriter, r *http.Request) { tok := r.PathValue("token") if err := r.ParseForm(); err != nil { fail(w, http.StatusBadRequest, "bad_request", err.Error()) return } username := strings.TrimSpace(r.FormValue("username")) password := r.FormValue("password") err := s.st.Update(func(st *store.State) error { inv := st.Invites[tok] if inv == nil || !inv.Usable(time.Now()) { return errors.New("this invite link has expired or has already been used") } if err := validUsername(username); err != nil { return err } if len(password) < MinPasswordLen { return errors.New("password must be at least 10 characters") } if password != r.FormValue("password2") { return errors.New("the two passwords do not match") } if st.UserByName(username) != nil { return errors.New("that username is taken") } hash, err := secret.Hash(password) if err != nil { return err } u := &store.User{ID: secret.ID(), Username: username, Email: strings.TrimSpace(r.FormValue("email")), PassHash: hash, CreatedAt: time.Now()} st.Users[u.ID] = u if inv.GroupID != "" { role := inv.Role if !role.Valid() { role = store.RoleMember } st.Memberships = append(st.Memberships, store.Membership{UserID: u.ID, GroupID: inv.GroupID, Role: role}) } inv.Uses++ return nil }) if err != nil { s.renderInvite(w, tok, err.Error()) return } s.renderDone(w, "Account created", "Sign in from the KeepOrg client with your new username and password.") } func validUsername(name string) error { if len(name) < 2 || len(name) > 64 { return errors.New("username must be between 2 and 64 characters") } for _, r := range name { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-', r == '_', r == '@': default: return errors.New("username may contain letters, digits and . - _ @ only") } } return nil }