Files
sm-user-plugin/account_contributions.go
Jakub Zych 32f92591d6 fix: account link proof by auth_time, code in the URL fragment and POST show
- BackendLinkConfirm reads the session's auth_time (bouncer.VerifyAuthTime);
  a token without it, or a refreshed old session, answers 409 stale_session
- POST /_user/api/v1/backend-link/show takes the code in the body; GET
  /backend-link/{code} stays, deprecated; both answer Cache-Control no-store
- My account answers <frontend_url><page_path>#code=<code>; new key
  golem15.user.backend_links.page_path (default /link-admin)
- TestBackendLink now crafts auth_time tokens; TestBackendLinkAuthTimeSmoke
2026-10-23 16:38:55 +02:00

146 lines
5.0 KiB
Go

package user
import (
"context"
"errors"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gorm.io/gorm"
)
var _ pact.HasAccountContributions = (*Plugin)(nil)
// backendLinkLang prefixes the phrase keys of the account link.
const backendLinkLang = "golem15.user::lang.backend_link."
// AccountContributions adds the "Frontend account" section to the admin's
// My account page (D-04): its status names the linked frontend account,
// "link" starts a link request and answers the site URL the administrator
// opens to sign in there as the frontend user and confirm (D-05; the link is
// written only by that confirmation), and "unlink" removes the link.
func (p *Plugin) AccountContributions() []pact.AccountContribution {
return []pact.AccountContribution{{
Name: "frontend_account",
Label: backendLinkLang + "section",
Description: backendLinkLang + "section_comment",
Status: p.frontendAccountStatus,
Actions: []pact.AccountAction{
{
Name: "link",
Label: backendLinkLang + "link",
Applies: func(ctx context.Context, adminID uint) (bool, error) {
link, err := p.backendLinks().LinkFor(ctx, adminID)
return link == nil, err
},
Run: p.startFrontendLink,
},
{
Name: "unlink",
Label: backendLinkLang + "unlink",
Confirm: backendLinkLang + "unlink_confirm",
Danger: true,
Applies: func(ctx context.Context, adminID uint) (bool, error) {
link, err := p.backendLinks().LinkFor(ctx, adminID)
return link != nil, err
},
Run: p.unlinkOwnFrontendAccount,
},
},
}}
}
// backendLinks returns the published link service, or one over the app.
func (p *Plugin) backendLinks() *classes.BackendLinks {
if p.app != nil {
if links, ok := p.app.Lookup[*classes.BackendLinks](); ok && links != nil {
return links
}
}
return &classes.BackendLinks{App: p.app}
}
// frontendAccountStatus answers "Linked to <email>" or "Not linked" in the
// request locale for the signed-in administrator.
func (p *Plugin) frontendAccountStatus(ctx context.Context, adminID uint) (string, error) {
link, err := p.backendLinks().LinkFor(ctx, adminID)
if err != nil {
return "", err
}
if link == nil {
return backendLinkLang + "status_unlinked", nil
}
email, err := p.frontendEmail(ctx, link.UserID)
if err != nil {
return "", err
}
const key = backendLinkLang + "status_linked"
if p.app != nil {
if tr, ok := p.app.Lookup[*phrasebook.Translator](); ok && tr != nil {
if text := tr.Get(ctx, key, map[string]string{"email": email}); text != "" && text != key {
return text, nil
}
}
}
return "Linked to " + email, nil
}
// frontendEmail reads the email of a frontend user, deactivated or not.
func (p *Plugin) frontendEmail(ctx context.Context, userID uint) (string, error) {
var db *gorm.DB
if tx, ok := cabana.TxFromContext(ctx); ok {
db = tx.Session(&gorm.Session{NewDB: true})
} else if p.app != nil {
db, _ = p.app.Lookup[*gorm.DB]()
}
if db == nil {
return "", errors.New("golem15.user: gorm handle is missing")
}
var emails []string
if err := db.WithContext(ctx).Unscoped().Model(&models.User{}).Where("id = ?", userID).Pluck("email", &emails).Error; err != nil {
return "", err
}
if len(emails) == 0 {
return "", nil
}
return emails[0], nil
}
// startFrontendLink creates a link request for the signed-in administrator
// and answers the site page that confirms it: golem15.user.frontend_url
// (else app.url), then golem15.user.backend_links.page_path (default
// /link-admin), then the code in the URL fragment. Browsers never send a
// fragment to a server, so the code stays out of access logs; the page reads
// it in the browser and posts it to /_user/api/v1/backend-link/show.
func (p *Plugin) startFrontendLink(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) {
plain, _, err := p.backendLinks().CreateLinkRequest(ctx, in.AdminID)
if err != nil {
return pact.AccountActionResult{}, err
}
return pact.AccountActionResult{
Message: backendLinkLang + "link_started",
URL: classes.FrontendURL(p.app) + classes.BackendLinkPagePath(p.app) + "#code=" + plain,
}, nil
}
// unlinkOwnFrontendAccount removes the signed-in administrator's link in
// the action's transaction.
func (p *Plugin) unlinkOwnFrontendAccount(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) {
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return pact.AccountActionResult{}, errors.New("golem15.user: unlinking needs the action's transaction")
}
links := p.backendLinks()
link, err := links.LinkFor(ctx, in.AdminID)
if err != nil {
return pact.AccountActionResult{}, err
}
if err := links.RemoveLink(ctx, tx, link); err != nil {
return pact.AccountActionResult{}, err
}
return pact.AccountActionResult{Message: backendLinkLang + "unlinked"}, nil
}