- 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
146 lines
5.0 KiB
Go
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
|
|
}
|