feat(email): one-tap confirm deeplink + client language (PR1b) #162
@@ -274,6 +274,10 @@ type LinkConfirmation struct {
|
|||||||
MergeOwner uuid.UUID
|
MergeOwner uuid.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsLogin reports whether the confirmation is a login (the caller mints a session)
|
||||||
|
// rather than a link (attach the identity, or drive a merge).
|
||||||
|
func (r LinkConfirmation) IsLogin() bool { return r.Purpose == purposeLogin }
|
||||||
|
|
||||||
// ConfirmByToken verifies a one-tap deeplink token and performs its purpose. A login
|
// ConfirmByToken verifies a one-tap deeplink token and performs its purpose. A login
|
||||||
// confirms the email identity, clears the guest flag and returns the account to sign
|
// confirms the email identity, clears the guest flag and returns the account to sign
|
||||||
// in. A link attaches the confirmed email to the pending account when the address is
|
// in. A link attaches the confirmed email to the pending account when the address is
|
||||||
|
|||||||
@@ -278,3 +278,98 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
|
|||||||
t.Error("confirming the login must clear the guest flag (promote to durable)")
|
t.Error("confirming the login must clear the guest flag (promote to durable)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// confirmToken extracts the one-tap deeplink token from the /app/#/confirm/<token> link
|
||||||
|
// the branded email carries.
|
||||||
|
var confirmToken = regexp.MustCompile(`/confirm/([A-Za-z0-9_-]+)`)
|
||||||
|
|
||||||
|
func tokenFromMail(t *testing.T, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
m := confirmToken.FindStringSubmatch(body)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatalf("no confirm token in mail body %q", body)
|
||||||
|
}
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfirmByTokenLogin: the one-tap deeplink token completes an email login,
|
||||||
|
// clearing the guest flag.
|
||||||
|
func TestConfirmByTokenLogin(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
email := "tok-login-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
|
id, err := svc.RequestLoginCode(ctx, email, "", "en")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request login: %v", err)
|
||||||
|
}
|
||||||
|
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("confirm by token: %v", err)
|
||||||
|
}
|
||||||
|
if !res.IsLogin() || res.Account != id {
|
||||||
|
t.Fatalf("login result = %+v, want login for %s", res, id)
|
||||||
|
}
|
||||||
|
if !identityConfirmed(t, account.KindEmail, email) {
|
||||||
|
t.Error("email identity must be confirmed after the token login")
|
||||||
|
}
|
||||||
|
if acc, _ := store.GetByID(ctx, id); acc.IsGuest {
|
||||||
|
t.Error("the token login must clear the guest flag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfirmByTokenLink: the token attaches a free email to the requesting account.
|
||||||
|
func TestConfirmByTokenLink(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
email := "tok-link-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
|
if err := svc.RequestLinkCode(ctx, acc, email); err != nil {
|
||||||
|
t.Fatalf("request link: %v", err)
|
||||||
|
}
|
||||||
|
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("confirm by token: %v", err)
|
||||||
|
}
|
||||||
|
if res.IsLogin() || res.NeedsMerge || res.Account != acc {
|
||||||
|
t.Fatalf("link result = %+v, want a plain link for %s", res, acc)
|
||||||
|
}
|
||||||
|
if !identityConfirmed(t, account.KindEmail, email) {
|
||||||
|
t.Error("email identity must be confirmed after the token link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfirmByTokenLinkMerge: a token for an address owned by another account signals
|
||||||
|
// a merge (leaving the token unconsumed for the interactive step).
|
||||||
|
func TestConfirmByTokenLinkMerge(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
email := "tok-merge-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
|
owner := provisionAccount(t)
|
||||||
|
if err := svc.RequestCode(ctx, owner, email); err != nil {
|
||||||
|
t.Fatalf("owner request: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ConfirmCode(ctx, owner, email, sixDigit.FindString(mailer.lastBody)); err != nil {
|
||||||
|
t.Fatalf("owner confirm: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
other := provisionAccount(t)
|
||||||
|
if err := svc.RequestLinkCode(ctx, other, email); err != nil {
|
||||||
|
t.Fatalf("link request: %v", err)
|
||||||
|
}
|
||||||
|
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("confirm by token: %v", err)
|
||||||
|
}
|
||||||
|
if !res.NeedsMerge || res.MergeOwner != owner {
|
||||||
|
t.Fatalf("merge result = %+v, want NeedsMerge with owner=%s", res, owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func (s *Server) registerRoutes() {
|
|||||||
in.POST("/sessions/guest", s.handleGuestAuth)
|
in.POST("/sessions/guest", s.handleGuestAuth)
|
||||||
in.POST("/sessions/email/request", s.handleEmailRequest)
|
in.POST("/sessions/email/request", s.handleEmailRequest)
|
||||||
in.POST("/sessions/email/login", s.handleEmailLogin)
|
in.POST("/sessions/email/login", s.handleEmailLogin)
|
||||||
|
in.POST("/sessions/email/confirm-link", s.handleEmailConfirmLink)
|
||||||
in.POST("/sessions/resolve", s.handleResolveSession)
|
in.POST("/sessions/resolve", s.handleResolveSession)
|
||||||
in.POST("/sessions/revoke", s.handleRevokeSession)
|
in.POST("/sessions/revoke", s.handleRevokeSession)
|
||||||
// Out-of-app push routing for the platform side-service: the
|
// Out-of-app push routing for the platform side-service: the
|
||||||
|
|||||||
@@ -212,6 +212,53 @@ func (s *Server) handleEmailLogin(c *gin.Context) {
|
|||||||
s.mintSession(c, acc)
|
s.mintSession(c, acc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// confirmLinkResponse is the outcome of a one-tap deeplink confirmation. For a login,
|
||||||
|
// Session carries the freshly minted credential (the deeplink page signs in with it);
|
||||||
|
// for a link, Status is "confirmed" or "merge_required" (the app completes the merge).
|
||||||
|
type confirmLinkResponse struct {
|
||||||
|
Purpose string `json:"purpose"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Session *sessionResponse `json:"session,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEmailConfirmLink verifies a one-tap deeplink token. A login mints a session
|
||||||
|
// (the deeplink page signs in with it); a link attaches the confirmed email, reporting
|
||||||
|
// "merge_required" when the address is owned by another account so the app drives the
|
||||||
|
// interactive merge. The token, not a request session, is the authorization — the page
|
||||||
|
// need not be signed in.
|
||||||
|
func (s *Server) handleEmailConfirmLink(c *gin.Context) {
|
||||||
|
var req tokenRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
|
||||||
|
abortBadRequest(c, "token is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := s.emails.ConfirmByToken(c.Request.Context(), req.Token)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.IsLogin() {
|
||||||
|
acc, err := s.accounts.GetByID(c.Request.Context(), res.Account)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess := sessionResponseFor(token, acc)
|
||||||
|
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "login", Status: "confirmed", Session: &sess})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status := "confirmed"
|
||||||
|
if res.NeedsMerge {
|
||||||
|
status = "merge_required"
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: status})
|
||||||
|
}
|
||||||
|
|
||||||
// tokenRequest carries an opaque session token.
|
// tokenRequest carries an opaque session token.
|
||||||
type tokenRequest struct {
|
type tokenRequest struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
|
|||||||
Reference in New Issue
Block a user