- 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)
508 lines
17 KiB
Go
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
|
|
}
|