package server import ( "errors" "fmt" "net/http" "net/url" "strings" "time" "cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/store" "cc.tiredsysadmin.keeporg/internal/totp" ) // 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 var ( errBadCredentials = errors.New("bad credentials") errTOTPRequired = errors.New("totp required") errTOTPInvalid = errors.New("totp invalid") ) // checkPassword validates a username, password and second factor against the // state. It is the one place password login is decided. func checkPassword(st *store.State, username, password, code string) (*store.User, error) { u := st.UserByName(username) if u == nil || u.Disabled || u.PassHash == "" { return nil, errBadCredentials } if !secret.Verify(u.PassHash, password) { return nil, errBadCredentials } if u.TOTPConfirmed { if strings.TrimSpace(code) == "" { return nil, errTOTPRequired } if !totp.Check(u.TOTPSecret, code, time.Now()) { return nil, errTOTPInvalid } } return u, nil } 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.st.Update(func(st *store.State) error { u, err := checkPassword(st, req.Username, req.Password, req.TOTP) if err != nil { return err } sess := s.newSession(st, u.ID, req.Label) resp = loginResponse{Token: sess.Token, ExpiresAt: sess.ExpiresAt, User: view(u)} return nil }) 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 } d := &store.DeviceAuth{ DeviceCode: secret.Token(), UserCode: secret.UserCode(), Label: req.Label, ReturnURL: req.ReturnURL, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(deviceTTL), } if err := s.st.Update(func(st *store.State) error { st.Devices[d.DeviceCode] = d return nil }); err != nil { fail(w, http.StatusInternalServerError, "internal", err.Error()) 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.st.Update(func(st *store.State) error { d := st.DeviceByUserCode(code) if d == nil || time.Now().After(d.ExpiresAt) { return errors.New("unknown or expired code") } returnURL = d.ReturnURL u, err := checkPassword(st, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp")) if err != nil { return err } d.SessionToken = s.newSession(st, u.ID, d.Label).Token return nil }) switch { 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 changePassword(st *store.State, username, current, totpCode, next, keepToken string) error { u, err := checkPassword(st, username, current, totpCode) if err != nil { return err } 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") } hash, err := secret.Hash(next) if err != nil { return err } 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.st.Update(func(st *store.State) error { return changePassword(st, u.Username, req.CurrentPassword, req.TOTP, req.NewPassword, bearer(r)) }) 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", "the current password is wrong") 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 }