package classes import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "strings" "time" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "github.com/jackc/pgx/v5/pgconn" "gorm.io/gorm" "gorm.io/gorm/clause" ) // Errors of the account link service. A request that is unknown, used or // expired is reported apart so callers can log the difference; the site // routes answer all three with the same 404. var ( ErrLinkRequestNotFound = errors.New("golem15.user: backend link request not found") ErrLinkRequestUsed = errors.New("golem15.user: backend link request already used") ErrLinkRequestExpired = errors.New("golem15.user: backend link request expired") ErrStaleSession = errors.New("golem15.user: the session was signed in before the link request") ErrAlreadyLinked = errors.New("golem15.user: one of the accounts is already linked") ErrBackendUserUnavailable = errors.New("golem15.user: the administrator is missing or not activated") ErrFrontendUserUnavailable = errors.New("golem15.user: the frontend user is missing or deactivated") ) // DefaultBackendLinkRequestTTL is how long a link request stays usable when // golem15.user.backend_links.request_ttl is not set. const DefaultBackendLinkRequestTTL = 10 * time.Minute var _ pact.MCPLinkedBackendResolver = (*BackendLinks)(nil) // BackendLinks is the verified link between a frontend user and a backend // administrator (D-03): one link per account on each side, written either // after the frontend user proves the account with a fresh sign-in // (CreateLinkRequest, then ConfirmLinkRequest) or by an administrator with // golem15.users.manage_backend_links on the Administrators screen (SetLink). // Boot publishes it as *BackendLinks and as pact.MCPLinkedBackendResolver. // // Every read and write uses the transaction cabana runs a hook or an action // in when the context carries one (cabana.TxFromContext), else the // application's database. type BackendLinks struct { App *backpack.App } // BackendLinkRequestTTL reads golem15.user.backend_links.request_ttl, a Go // duration (10m by default). A value that does not parse or is not positive // is an error, so a typo fails the boot instead of silently changing the // lifetime. func BackendLinkRequestTTL(app *backpack.App) (time.Duration, error) { if app == nil || app.Config == nil { return DefaultBackendLinkRequestTTL, nil } raw := strings.TrimSpace(app.Config.String("golem15.user.backend_links.request_ttl")) if raw == "" { return DefaultBackendLinkRequestTTL, nil } ttl, err := time.ParseDuration(raw) if err != nil || ttl <= 0 { return 0, fmt.Errorf("golem15.user: backend_links.request_ttl must be a positive duration such as 10m, got %q", raw) } return ttl, nil } // FrontendURL is the site the plugin sends people to for pages it does not // serve itself (the account link page): golem15.user.frontend_url, falling // back to app.url, without a trailing slash. Empty when neither is set. func FrontendURL(app *backpack.App) string { if app == nil || app.Config == nil { return "" } base := strings.TrimSpace(app.Config.String("golem15.user.frontend_url")) if base == "" { base = strings.TrimSpace(app.Config.String("app.url")) } return strings.TrimRight(base, "/") } // db returns the handle a call works on: the cabana transaction on ctx, or // the application's database. func (s *BackendLinks) db(ctx context.Context) (*gorm.DB, error) { if tx, ok := cabana.TxFromContext(ctx); ok { return fresh(ctx, tx), nil } if s == nil || s.App == nil { return nil, fmt.Errorf("golem15.user: app is nil") } gdb, ok := s.App.Lookup[*gorm.DB]() if !ok || gdb == nil { return nil, fmt.Errorf("golem15.user: gorm handle is missing") } return gdb.WithContext(ctx), nil } // hashLinkCode is the only form of a link code that is stored. func hashLinkCode(plain string) string { sum := sha256.Sum256([]byte(plain)) return hex.EncodeToString(sum[:]) } // validLinkCode reports whether plain has the shape CreateLinkRequest // issues: 64 lower-case hex characters. func validLinkCode(plain string) bool { if len(plain) != 64 { return false } for _, r := range plain { if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') { return false } } return true } // CreateLinkRequest starts a link for the administrator: a one-shot code // (32 random bytes, hex) valid for golem15.user.backend_links.request_ttl, // stored as its SHA-256. The administrator's earlier requests that were not // used are dropped, so only the newest code works. The plain code is // returned once, for the site URL. func (s *BackendLinks) CreateLinkRequest(ctx context.Context, backendUserID uint) (string, time.Time, error) { if backendUserID == 0 { return "", time.Time{}, ErrBackendUserUnavailable } ttl, err := BackendLinkRequestTTL(s.app()) if err != nil { return "", time.Time{}, err } db, err := s.db(ctx) if err != nil { return "", time.Time{}, err } plain, err := newCode() if err != nil { return "", time.Time{}, err } now := time.Now() req := &models.BackendLinkRequest{ CodeHash: hashLinkCode(plain), BackendUserID: backendUserID, ExpiresAt: now.Add(ttl), CreatedAt: now, } if err := fresh(ctx, db).Where("backend_user_id = ? AND used_at IS NULL", backendUserID). Delete(&models.BackendLinkRequest{}).Error; err != nil { return "", time.Time{}, err } if err := fresh(ctx, db).Create(req).Error; err != nil { return "", time.Time{}, err } return plain, req.ExpiresAt, nil } // LinkRequestByCode returns a usable request and its administrator for the // site's link page. An unknown, used or expired code is // ErrLinkRequestNotFound, ErrLinkRequestUsed or ErrLinkRequestExpired; an // administrator who was deactivated or deleted since is // ErrBackendUserUnavailable. func (s *BackendLinks) LinkRequestByCode(ctx context.Context, plain string) (*models.BackendLinkRequest, *cabana.BackendUser, error) { if !validLinkCode(plain) { return nil, nil, ErrLinkRequestNotFound } db, err := s.db(ctx) if err != nil { return nil, nil, err } var rows []models.BackendLinkRequest if err := fresh(ctx, db).Where("code_hash = ?", hashLinkCode(plain)).Limit(1).Find(&rows).Error; err != nil { return nil, nil, err } if len(rows) == 0 { return nil, nil, ErrLinkRequestNotFound } req := &rows[0] if err := requestUsable(req, time.Now()); err != nil { return nil, nil, err } admin, err := activeBackendUser(ctx, db, req.BackendUserID) if err != nil { return nil, nil, err } return req, admin, nil } func requestUsable(req *models.BackendLinkRequest, now time.Time) error { if req.UsedAt != nil { return ErrLinkRequestUsed } if !req.ExpiresAt.After(now) { return ErrLinkRequestExpired } return nil } // ConfirmLinkRequest stores the link between the signed-in frontend user and // the administrator who created the request, in one transaction: the request // row is locked, must be unused and unexpired, and the session must have // been signed in at or after the request was created (sessionIssuedAt is // the JWT iat, compared in whole seconds because iat has no fraction), so a // browser that was already signed in has to sign in again (D-05). Neither // account may be linked already (ErrAlreadyLinked) and the administrator // must still be activated (ErrBackendUserUnavailable). The request is used // up with the link; BackendLinkedEvent fires inside the transaction and the // two pact.MCPSubjectChanged events after it commits. func (s *BackendLinks) ConfirmLinkRequest(ctx context.Context, plain string, frontendUserID uint, sessionIssuedAt time.Time) (*models.BackendLink, error) { if !validLinkCode(plain) { return nil, ErrLinkRequestNotFound } if frontendUserID == 0 { return nil, ErrFrontendUserUnavailable } gdb, err := s.pool() if err != nil { return nil, err } var link *models.BackendLink err = lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error { var rows []models.BackendLinkRequest if err := fresh(ctx, tx).Clauses(clause.Locking{Strength: "UPDATE"}). Where("code_hash = ?", hashLinkCode(plain)).Limit(1).Find(&rows).Error; err != nil { return err } if len(rows) == 0 { return ErrLinkRequestNotFound } req := &rows[0] now := time.Now() if err := requestUsable(req, now); err != nil { return err } if sessionIssuedAt.IsZero() || sessionIssuedAt.Before(req.CreatedAt.Truncate(time.Second)) { return ErrStaleSession } if _, err := activeBackendUser(ctx, tx, req.BackendUserID); err != nil { return err } created, err := s.insertLink(ctx, tx, frontendUserID, req.BackendUserID, nil) if err != nil { return err } if err := fresh(ctx, tx).Model(&models.BackendLinkRequest{}).Where("id = ?", req.ID). Update("used_at", now).Error; err != nil { return err } link = created return nil }) if err != nil { return nil, err } return link, nil } // SetLink stores a link an administrator sets on the Administrators screen // (D-07): the frontend user must exist and not be deactivated // (ErrFrontendUserUnavailable), the administrator must be activated // (ErrBackendUserUnavailable) and neither may be linked already // (ErrAlreadyLinked). linkedBy is recorded (0 records none). It runs in tx, // fires BackendLinkedEvent there and the two pact.MCPSubjectChanged events // after tx commits. func (s *BackendLinks) SetLink(ctx context.Context, tx *gorm.DB, frontendUserID, backendUserID, linkedBy uint) (*models.BackendLink, error) { if tx == nil { return nil, fmt.Errorf("golem15.user: setting a link needs a transaction") } var users []models.User if err := fresh(ctx, tx).Where("id = ?", frontendUserID).Limit(1).Find(&users).Error; err != nil { return nil, err } if frontendUserID == 0 || len(users) == 0 { return nil, ErrFrontendUserUnavailable } if _, err := activeBackendUser(ctx, tx, backendUserID); err != nil { return nil, err } var by *uint if linkedBy != 0 { by = &linkedBy } return s.insertLink(ctx, tx, frontendUserID, backendUserID, by) } // insertLink writes one link in tx after checking both sides are free; a // concurrent writer that wins the unique index also ends in // ErrAlreadyLinked. func (s *BackendLinks) insertLink(ctx context.Context, tx *gorm.DB, frontendUserID, backendUserID uint, linkedBy *uint) (*models.BackendLink, error) { var taken int64 if err := fresh(ctx, tx).Model(&models.BackendLink{}). Where("user_id = ? OR backend_user_id = ?", frontendUserID, backendUserID). Count(&taken).Error; err != nil { return nil, err } if taken > 0 { return nil, ErrAlreadyLinked } link := &models.BackendLink{UserID: frontendUserID, BackendUserID: backendUserID, LinkedBy: linkedBy, CreatedAt: time.Now()} if err := fresh(ctx, tx).Create(link).Error; err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { return nil, ErrAlreadyLinked } return nil, err } app := s.app() if app != nil && app.Events != nil { if err := app.Events.Fire(ctx, &BackendLinkedEvent{Link: link, Tx: tx}); err != nil { return nil, fmt.Errorf("%s: %w", BackendLinkedEventName, err) } } FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardUser, link.UserID, pact.MCPSubjectLink) FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardBackend, link.BackendUserID, pact.MCPSubjectLink) return link, nil } // RemoveLink deletes link in tx, fires BackendUnlinkedEvent there and the // two pact.MCPSubjectChanged link events after tx commits. A link that is // already gone is not an error and fires nothing. func (s *BackendLinks) RemoveLink(ctx context.Context, tx *gorm.DB, link *models.BackendLink) error { if link == nil || link.ID == 0 { return nil } if tx == nil { return fmt.Errorf("golem15.user: removing a link needs a transaction") } res := fresh(ctx, tx).Where("id = ?", link.ID).Delete(&models.BackendLink{}) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return nil } app := s.app() if app != nil && app.Events != nil { if err := app.Events.Fire(ctx, &BackendUnlinkedEvent{Link: link, Tx: tx}); err != nil { return fmt.Errorf("%s: %w", BackendUnlinkedEventName, err) } } FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardUser, link.UserID, pact.MCPSubjectLink) FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardBackend, link.BackendUserID, pact.MCPSubjectLink) return nil } // LinkFor returns the link of the administrator, or nil. func (s *BackendLinks) LinkFor(ctx context.Context, backendUserID uint) (*models.BackendLink, error) { return s.linkWhere(ctx, "backend_user_id = ?", backendUserID) } // LinkOf returns the link of the frontend user, or nil. func (s *BackendLinks) LinkOf(ctx context.Context, frontendUserID uint) (*models.BackendLink, error) { return s.linkWhere(ctx, "user_id = ?", frontendUserID) } func (s *BackendLinks) linkWhere(ctx context.Context, where string, id uint) (*models.BackendLink, error) { if id == 0 { return nil, nil } db, err := s.db(ctx) if err != nil { return nil, err } var rows []models.BackendLink if err := fresh(ctx, db).Where(where, id).Limit(1).Find(&rows).Error; err != nil { return nil, err } if len(rows) == 0 { return nil, nil } return &rows[0], nil } // LinkedBackendUser implements pact.MCPLinkedBackendResolver with one // indexed query per call (no cache): the administrator linked to the // frontend user, false when there is no link, when the frontend user is // deactivated, or when the administrator is deactivated or deleted. func (s *BackendLinks) LinkedBackendUser(ctx context.Context, frontendUserID uint) (pact.LinkedBackendUser, bool, error) { if frontendUserID == 0 { return pact.LinkedBackendUser{}, false, nil } db, err := s.db(ctx) if err != nil { return pact.LinkedBackendUser{}, false, err } var rows []struct { ID uint Login string FirstName *string LastName *string } err = fresh(ctx, db).Raw(`SELECT b.id, b.login, b.first_name, b.last_name FROM golem15_user_backend_links l JOIN backend_users b ON b.id = l.backend_user_id JOIN users u ON u.id = l.user_id WHERE l.user_id = ? AND b.deleted_at IS NULL AND b.is_activated = TRUE AND u.deleted_at IS NULL LIMIT 1`, frontendUserID).Scan(&rows).Error if err != nil { return pact.LinkedBackendUser{}, false, err } if len(rows) == 0 { return pact.LinkedBackendUser{}, false, nil } row := rows[0] return pact.LinkedBackendUser{ID: row.ID, Login: row.Login, DisplayName: backendDisplayName(deref(row.FirstName), deref(row.LastName), row.Login)}, true, nil } // BackendDisplayName is the name the link pages show for an administrator: // first and last name, else the login. func BackendDisplayName(admin *cabana.BackendUser) string { if admin == nil { return "" } return backendDisplayName(admin.FirstName, admin.LastName, admin.Login) } func backendDisplayName(first, last, login string) string { if name := strings.TrimSpace(strings.TrimSpace(first) + " " + strings.TrimSpace(last)); name != "" { return name } return login } func deref(s *string) string { if s == nil { return "" } return *s } // activeBackendUser loads an activated, not deleted administrator, else // ErrBackendUserUnavailable. func activeBackendUser(ctx context.Context, db *gorm.DB, id uint) (*cabana.BackendUser, error) { if id == 0 { return nil, ErrBackendUserUnavailable } var rows []cabana.BackendUser if err := fresh(ctx, db).Where("id = ? AND is_activated = TRUE", id).Limit(1).Find(&rows).Error; err != nil { return nil, err } if len(rows) == 0 { return nil, ErrBackendUserUnavailable } return &rows[0], nil } // RegisterBackendLinkListeners removes a frontend user's link inside the // transaction that deletes (DeletingEvent) or deactivates // (DeactivatingEvent) the user, and fires pact.MCPSubjectChanged deleted // for the user and link for the administrator after it commits. func (s *BackendLinks) RegisterBackendLinkListeners(app *backpack.App) { if app == nil || app.Events == nil { return } const owner = "golem15.user.backend_links" app.Events.Listen(owner, func(ctx context.Context, e *DeletingEvent) error { if e == nil { return nil } return s.dropUserLink(ctx, e.Tx, e.User) }) app.Events.Listen(owner, func(ctx context.Context, e *DeactivatingEvent) error { if e == nil { return nil } return s.dropUserLink(ctx, e.Tx, e.User) }) } func (s *BackendLinks) dropUserLink(ctx context.Context, tx *gorm.DB, user *models.User) error { if user == nil || user.ID == 0 || tx == nil { return nil } var rows []models.BackendLink if err := fresh(ctx, tx).Where("user_id = ?", user.ID).Limit(1).Find(&rows).Error; err != nil { return err } if len(rows) > 0 { if err := s.RemoveLink(ctx, tx, &rows[0]); err != nil { return err } } FireSubjectChangedAfterCommit(ctx, s.app(), tx, SubjectGuardUser, user.ID, pact.MCPSubjectDeleted) return nil } func (s *BackendLinks) app() *backpack.App { if s == nil { return nil } return s.App } func (s *BackendLinks) pool() (*gorm.DB, error) { if s == nil || s.App == nil { return nil, fmt.Errorf("golem15.user: app is nil") } gdb, ok := s.App.Lookup[*gorm.DB]() if !ok || gdb == nil { return nil, fmt.Errorf("golem15.user: gorm handle is missing") } return gdb, nil }