- 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
104 lines
3.8 KiB
Go
104 lines
3.8 KiB
Go
package user
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
// craftSignInToken is craftToken with an auth_time claim: a session token
|
|
// issued at iat for a sign-in at authTime.
|
|
func craftSignInToken(t *testing.T, sub, jti string, iat, authTime time.Time) string {
|
|
t.Helper()
|
|
claims := jwt.MapClaims{
|
|
"sub": sub,
|
|
"iss": "http://example.com/_user/api/v1/login",
|
|
"iat": iat.Unix(),
|
|
"nbf": iat.Unix(),
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
"jti": jti,
|
|
"prv": prvHash,
|
|
bouncer.ClaimAuthTime: authTime.Unix(),
|
|
}
|
|
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(sessionSecret))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return signed
|
|
}
|
|
|
|
// TestBackendLinkAuthTimeSmoke: the account link proof is the sign-in time.
|
|
// A session signed in before the request and refreshed after it is refused
|
|
// with stale_session, a sign-in after the request confirms, POST show
|
|
// answers the administrator for the code in the body, and My account's URL
|
|
// carries the code in the fragment, never in a query string. Skips under
|
|
// -short.
|
|
func TestBackendLinkAuthTimeSmoke(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
user := env.seedUser(t, "auth-time", nil)
|
|
sub := strconv.FormatUint(uint64(user.ID), 10)
|
|
admin := env.admin(t)
|
|
login := env.adminLogin(t, adminID(t, admin))
|
|
|
|
// 1. The user signed in an hour ago.
|
|
old := craftSignInToken(t, sub, "auth-time-old-"+adminStamp(), time.Now().Add(-time.Hour), time.Now().Add(-time.Hour))
|
|
|
|
// 2. The administrator starts a link; 6. its URL holds the code in the
|
|
// fragment only.
|
|
var started struct {
|
|
URL string `json:"url"`
|
|
}
|
|
adminInto(t, env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/link"), admin, map[string]any{}), &started)
|
|
if !strings.Contains(started.URL, "/link-admin#code=") || strings.Contains(started.URL, "?") {
|
|
t.Fatalf("link URL = %q", started.URL)
|
|
}
|
|
m := linkURL.FindStringSubmatch(started.URL)
|
|
if m == nil {
|
|
t.Fatalf("link URL = %q", started.URL)
|
|
}
|
|
code := m[1]
|
|
|
|
// 5. POST show answers the administrator for the code in the body.
|
|
rec := env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/backend-link/show", "", map[string]string{"code": code})
|
|
var shown struct {
|
|
Data struct {
|
|
Admin map[string]any `json:"admin"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &shown); err != nil || shown.Data.Admin["login"] != login {
|
|
t.Fatalf("POST show = %s (%v)", rec.Body.String(), err)
|
|
}
|
|
if got := rec.Header().Get("Cache-Control"); got != "no-store, private" {
|
|
t.Fatalf("POST show Cache-Control = %q", got)
|
|
}
|
|
env.expect(t, http.StatusNotFound, http.MethodPost, "/_user/api/v1/backend-link/show", "", map[string]string{"code": strings.Repeat("0", 64)})
|
|
|
|
// 3. Refreshing the old session after the request does not make it a
|
|
// fresh sign-in.
|
|
rec = env.refresh(t, old)
|
|
var refreshed struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &refreshed); rec.Code != http.StatusOK || err != nil || refreshed.Token == "" {
|
|
t.Fatalf("refresh = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if status, body := env.confirmLink(t, refreshed.Token, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) {
|
|
t.Fatalf("refreshed confirm = %d %s", status, body)
|
|
}
|
|
|
|
// 4. A sign-in after the request confirms.
|
|
status, fresh := env.userLogin(t, user.Email, "correct-horse")
|
|
if status != http.StatusOK || fresh == "" {
|
|
t.Fatalf("login = %d", status)
|
|
}
|
|
if status, body := env.confirmLink(t, fresh, code); status != http.StatusOK || !strings.Contains(body, `"login":"`+login+`"`) {
|
|
t.Fatalf("fresh confirm = %d %s", status, body)
|
|
}
|
|
}
|