Files
sm-user-plugin/classes/backend_links.go
Jakub Zych 7ac80c9aea feat: verified frontend account link for administrators
- golem15_user_backend_links (1:1, both sides unique) and one-shot hashed
  golem15_user_backend_link_requests (golem15.user.backend_links.request_ttl, 10m)
- classes.BackendLinks: CreateLinkRequest, ConfirmLinkRequest (row lock,
  JWT iat not before the request), SetLink, RemoveLink, LinkedBackendUser;
  published as *classes.BackendLinks and pact.MCPLinkedBackendResolver
- GET /_user/api/v1/backend-link/{code} and POST /backend-link/confirm (jwt.auth)
- My account section frontend_account with link and unlink
- BackendLinkedEvent, BackendUnlinkedEvent and pact.MCPSubjectChanged after commit;
  deleting or deactivating a frontend user removes its link
- golem15.user.frontend_url (falls back to app.url)
2026-10-23 12:03:26 +02:00

508 lines
17 KiB
Go

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
}