mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-05 16:02:10 +00:00
131 lines
5.9 KiB
Go
131 lines
5.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"html/template"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/warmbly/warmbly/internal/app/poollink"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/pkg/crypt"
|
|
)
|
|
|
|
// A brokered mailbox sign-in for a linked instance passes through this page on
|
|
// the cloud's own origin. It names who is asking, and the cookie it sets is
|
|
// what the provider callback requires, so the sign-in completes only in the
|
|
// browser where the person read this page and chose to continue.
|
|
|
|
const brokerCookieName = "warmbly_broker"
|
|
|
|
// brokerCookiePath covers the consent page, its continue step and both provider callbacks.
|
|
const brokerCookiePath = "/addresses/"
|
|
|
|
var brokerConsentPage = template.Must(template.New("broker-consent").Parse(`<!doctype html>
|
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Connect a mailbox to Warmbly Cloud</title>
|
|
<style>
|
|
html,body{margin:0;min-height:100%;font:14px/1.5 -apple-system,Segoe UI,Inter,sans-serif;color:#0f172a;background:#f8fafc}
|
|
.wrap{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:16px;box-sizing:border-box}
|
|
.card{max-width:460px;padding:24px 28px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;box-shadow:0 8px 24px -12px rgba(15,23,42,.18)}
|
|
.h{font-size:15px;font-weight:600;margin:0 0 10px}
|
|
.t{font-size:13px;color:#334155;margin:0 0 10px}
|
|
.w{font-size:12.5px;color:#92400e;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;padding:8px 10px;margin:12px 0 16px}
|
|
.b{display:inline-block;background:#0284c7;color:#fff;text-decoration:none;font-size:13px;font-weight:500;padding:8px 14px;border-radius:6px}
|
|
.m{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;background:#f1f5f9;padding:1px 5px;border-radius:4px;word-break:break-all}
|
|
</style></head>
|
|
<body><div class="wrap"><div class="card">
|
|
<p class="h">Connect a {{.Provider}} mailbox to Warmbly Cloud</p>
|
|
<p class="t">The self-hosted Warmbly instance at <span class="m">{{.Host}}</span>{{if .Name}} ({{.Name}}){{end}} asked to connect a mailbox through Warmbly Cloud.</p>
|
|
<p class="t">If you continue and sign in, the mailbox is added to the Warmbly Cloud workspace <strong>{{if .Workspace}}{{.Workspace}}{{else}}linked to that instance{{end}}</strong>, and that instance can send and read mail as it.</p>
|
|
<p class="w">Only continue if you started this yourself, from that instance. If someone sent you this link, close this tab.</p>
|
|
<a class="b" href="{{.ContinueURL}}">Continue to {{.Provider}}</a>
|
|
</div></div></body></html>`))
|
|
|
|
func brokerProviderName(p models.InboxProvider) string {
|
|
if p == models.InboxProviderOutlook {
|
|
return "Microsoft"
|
|
}
|
|
return "Google"
|
|
}
|
|
|
|
// renderBrokerNotice answers every refused step with a plain page; nothing on it is actionable.
|
|
func renderBrokerNotice(c *gin.Context, xerr *errx.Error) {
|
|
status, body := http.StatusBadRequest, "This sign-in link has expired or was already used. Start again from your instance."
|
|
if xerr != nil && xerr.Code != errx.Internal {
|
|
body = xerr.Message
|
|
} else if xerr != nil {
|
|
status, body = http.StatusInternalServerError, "Something went wrong. Start again from your instance."
|
|
}
|
|
renderNotice(c, status, "Sign-in not started", body)
|
|
}
|
|
|
|
func setBrokerCookie(c *gin.Context, value string, maxAge int) {
|
|
secure := c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
|
http.SetCookie(c.Writer, &http.Cookie{
|
|
Name: brokerCookieName, Value: value, Path: brokerCookiePath, MaxAge: maxAge,
|
|
HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
// PoolLinkOAuthConsentPage is GET /addresses/connect?state=..., the page a brokered sign-in opens on.
|
|
func (h *Handler) PoolLinkOAuthConsentPage(c *gin.Context) {
|
|
c.Header("Cache-Control", "no-store")
|
|
c.Header("Referrer-Policy", "no-referrer")
|
|
if h.PoolLinkService == nil {
|
|
renderBrokerNotice(c, nil)
|
|
return
|
|
}
|
|
state := c.Query("state")
|
|
consent, xerr := h.PoolLinkService.DescribeOAuthConsent(c.Request.Context(), state)
|
|
if xerr != nil {
|
|
renderBrokerNotice(c, xerr)
|
|
return
|
|
}
|
|
// One value per browser, so two sign-ins open side by side both still complete.
|
|
token, _ := c.Cookie(brokerCookieName)
|
|
if len(token) < 16 {
|
|
var err error
|
|
if token, err = crypt.Nonce(); err != nil {
|
|
renderBrokerNotice(c, errx.InternalError())
|
|
return
|
|
}
|
|
}
|
|
setBrokerCookie(c, token, 15*60)
|
|
data := struct{ Provider, Host, Name, Workspace, ContinueURL string }{
|
|
Provider: brokerProviderName(consent.Provider),
|
|
Host: consent.InstanceHost,
|
|
Name: consent.InstanceName,
|
|
Workspace: consent.WorkspaceName,
|
|
ContinueURL: poollink.BrokerConsentPath + "/continue?" + url.Values{"state": {state}, "t": {token}}.Encode(),
|
|
}
|
|
c.Header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
|
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
|
c.Status(http.StatusOK)
|
|
_ = brokerConsentPage.Execute(c.Writer, data)
|
|
}
|
|
|
|
// PoolLinkOAuthContinue is the consent page's button: it binds the sign-in to this browser and opens the provider.
|
|
func (h *Handler) PoolLinkOAuthContinue(c *gin.Context) {
|
|
c.Header("Cache-Control", "no-store")
|
|
c.Header("Referrer-Policy", "no-referrer")
|
|
if h.PoolLinkService == nil {
|
|
renderBrokerNotice(c, nil)
|
|
return
|
|
}
|
|
// The link carries the cookie's value, which only the consent page rendered in this browser knows.
|
|
cookie, _ := c.Cookie(brokerCookieName)
|
|
if cookie == "" || subtle.ConstantTimeCompare([]byte(cookie), []byte(c.Query("t"))) != 1 {
|
|
renderBrokerNotice(c, poollink.ErrOAuthBrowser)
|
|
return
|
|
}
|
|
to, xerr := h.PoolLinkService.ContinueOAuth(c.Request.Context(), c.Query("state"), cookie)
|
|
if xerr != nil {
|
|
renderBrokerNotice(c, xerr)
|
|
return
|
|
}
|
|
c.Redirect(http.StatusFound, to)
|
|
}
|