chore(deps): bump go modules in backend and example

Backend (backend/go.mod):
- github.com/go-pkgz/auth/v2 v2.1.2 → v2.1.4
- github.com/klauspost/compress v1.18.5 → v1.18.6
- github.com/redis/go-redis/v9 v9.18.0 → v9.19.0
- github.com/slack-go/slack v0.21.1 → v0.23.1
- golang.org/x/crypto v0.50.0 → v0.51.0
- golang.org/x/image v0.39.0 → v0.40.0
- golang.org/x/net v0.53.0 → v0.54.0
- golang.org/x/sys v0.43.0 → v0.44.0
- golang.org/x/text v0.36.0 → v0.37.0

Example (backend/_example/memory_store/go.mod):
- golang.org/x/crypto v0.50.0 → v0.51.0
- golang.org/x/image v0.39.0 → v0.40.0
- golang.org/x/net v0.53.0 → v0.54.0
- golang.org/x/sys v0.43.0 → v0.44.0

Transitive cleanup: github.com/dgryski/go-rendezvous is no longer required
after redis/go-redis bump and gets pruned by `go mod tidy`.

`go mod tidy` + `go mod vendor` run on both modules. Both build with -race
and full test suites pass.
This commit is contained in:
Dmitry Verkhoturov
2026-05-20 20:09:47 -05:00
committed by Umputun
parent f3a7dea1f1
commit 45c17a913f
130 changed files with 14730 additions and 25980 deletions
+69 -9
View File
@@ -7,6 +7,7 @@ import (
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/go-pkgz/rest"
@@ -26,14 +27,16 @@ type Client struct {
// Service provides higher level wrapper allowing to construct everything and get back token middleware
type Service struct {
logger logger.L
opts Opts
jwtService *token.Service
providers []provider.Service
authMiddleware middleware.Authenticator
avatarProxy *avatar.Proxy
issuer string
useGravatar bool
logger logger.L
opts Opts
jwtService *token.Service
providers []provider.Service
authMiddleware middleware.Authenticator
avatarProxy *avatar.Proxy
issuer string
useGravatar bool
verifConfirmStore provider.VerifConfirmationStore
verifConfirmStoreOnce sync.Once
}
// Opts is a full set of all parameters to initialize Service
@@ -84,6 +87,15 @@ type Opts struct {
Logger logger.L // logger interface, default is no logging at all
RefreshCache middleware.RefreshCache // optional cache to keep refreshed tokens
ErrorHandler middleware.ErrorHandlerFunc // custom error handler for auth failures
// VerifConfirmationStore enforces one-shot consumption of email
// confirmation tokens issued by the verify provider. The default
// (nil) installs an in-memory store on first use of AddVerifProvider —
// fine for single-instance deployments. Multi-instance deployments
// MUST supply a shared backend (e.g. Redis) implementing
// provider.VerifConfirmationStore, otherwise replay rejection works
// only on the instance that consumed the token.
VerifConfirmationStore provider.VerifConfirmationStore
}
// NewService initializes everything
@@ -225,7 +237,39 @@ func (s *Service) Handlers() (authHandler, avatarHandler http.Handler) {
p.Handler(w, r)
}
return http.HandlerFunc(ah), http.HandlerFunc(s.avatarProxy.Handler)
return withSecurityHeaders(http.HandlerFunc(ah)), withSecurityHeaders(http.HandlerFunc(s.avatarProxy.Handler))
}
// withSecurityHeaders wraps an auth response handler to apply strict CSP and nosniff
// on every response. The go-pkgz/auth package's own response surface is JSON-only
// for auth routes and images for the avatar route — no built-in HTML rendering
// anywhere — so this CSP is unconditionally safe and gives the auth origin
// defense-in-depth against any future trust-boundary regression that might emit a
// renderable body.
//
// - Content-Security-Policy: default-src 'none'; sandbox — blocks inline scripts
// and event handlers even if a body is ever served as HTML by mistake; the
// sandbox directive additionally isolates any rendered document from this origin.
// - X-Content-Type-Options: nosniff — prevents browsers from MIME-overriding the
// declared Content-Type to a more dangerous one.
//
// The avatar Handler additionally sets Content-Disposition: inline; filename="avatar"
// inside itself, so direct callers (tests, custom mounts) still get the full header
// set without going through this wrapper.
//
// CONSUMER NOTE: custom providers added via Service.AddCustomHandler / AddProvider
// are also wrapped. If a custom provider renders HTML (login forms, JS-based flows,
// the dev_provider's login page, etc.), the strict CSP will block inline scripts and
// event handlers on those pages. Such providers should either (a) override the CSP
// for their own response by calling w.Header().Set("Content-Security-Policy", ...)
// before writing — Set replaces the wrapper's value — or (b) move any required
// scripts/styles to external files served from 'self'.
func withSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox; frame-ancestors 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})
}
// Middleware returns auth middleware
@@ -435,6 +479,21 @@ func (s *Service) AddDirectProviderWithUserIDFunc(name string, credChecker provi
// AddVerifProvider adds provider user's verification sent by sender
func (s *Service) AddVerifProvider(name, msgTmpl string, sender provider.Sender) {
s.verifConfirmStoreOnce.Do(func() {
store := s.opts.VerifConfirmationStore
// guard against a typed-nil VerifConfirmationStoreFunc: a non-nil
// interface wrapping a nil func would survive the != nil check below
// and silently disable replay protection (the handler-level guard
// at LoginHandler then normalizes it to nil).
if fn, ok := store.(provider.VerifConfirmationStoreFunc); ok && fn == nil {
store = nil
}
if store != nil {
s.verifConfirmStore = store
return
}
s.verifConfirmStore = provider.NewInMemoryVerifStore()
})
dh := provider.VerifyHandler{
L: s.logger,
ProviderName: name,
@@ -446,6 +505,7 @@ func (s *Service) AddVerifProvider(name, msgTmpl string, sender provider.Sender)
UseGravatar: s.useGravatar,
URL: s.opts.URL,
AllowedRedirectHosts: s.opts.AllowedRedirectHosts,
ConfirmationStore: s.verifConfirmStore,
}
s.addProvider(dh)
}
+217 -54
View File
@@ -11,6 +11,7 @@ import (
"image/png"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -18,6 +19,7 @@ import (
"github.com/go-pkgz/rest"
"github.com/rrivera/identicon"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp" // register WebP decoder so Discord-style .webp avatars validate
"github.com/go-pkgz/auth/v2/logger"
"github.com/go-pkgz/auth/v2/token"
@@ -26,6 +28,19 @@ import (
// http.sniffLen is 512 bytes which is how much we need to read to detect content type
const sniffLen = 512
// maxAvatarFetchSize bounds the bytes read from a remote avatar URL. 10 MiB is
// generous for any reasonable avatar (Telegram caps photo at 5 MiB; Gravatar is
// much smaller); the cap protects Proxy.Put against an upstream sending an
// unbounded body that would exhaust process memory inside resize.
const maxAvatarFetchSize = 10 << 20
// maxAvatarPixels caps the declared pixel count of an avatar before any raster
// decode is allowed. Without this, a tiny compressed "decompression bomb" image
// declaring e.g. 65535x65535 px would force image.Decode to allocate gigabytes
// of pixel memory and OOM the auth service on a single login attempt. 16 MP
// covers any realistic avatar (~4096x4096) while keeping peak allocation bounded.
const maxAvatarPixels = 16 * 1024 * 1024
// Proxy provides http handler for avatars from avatar.Store
// On user login token will call Put and it will retrieve and save picture locally.
type Proxy struct {
@@ -45,7 +60,7 @@ func (p *Proxy) Put(u token.User, client *http.Client) (avatarURL string, err er
return "", fmt.Errorf("no picture for %s: %w", userID, e)
}
// put returns avatar base name, like 123456.image
avatarID, e := p.Store.Put(userID, p.resize(bytes.NewBuffer(b), p.ResizeLimit))
avatarID, e := p.Store.Put(userID, p.resize(b, p.ResizeLimit))
if e != nil {
return "", e
}
@@ -61,30 +76,73 @@ func (p *Proxy) Put(u token.User, client *http.Client) (avatarURL string, err er
body, err := p.load(u.Picture, client)
if err != nil {
p.Logf("[DEBUG] failed to fetch avatar from the orig %s, %v", u.Picture, err)
p.Logf("[DEBUG] failed to fetch avatar from the orig %s, %v", redactAvatarURL(u.Picture), err)
return genIdenticon(u.ID)
}
defer func() {
if e := body.Close(); e != nil {
p.Logf("[WARN] can't close response body, %s", e)
}
}()
resized := p.resize(body, p.ResizeLimit)
if resized == nil {
// non-image upstream — refuse to store attacker-controlled bytes under
// the user's avatar id and fall back to a generated identicon instead.
p.Logf("[WARN] upstream avatar from %s is not a valid image, using identicon", redactAvatarURL(u.Picture))
return genIdenticon(u.ID)
}
avatarID, err := p.Store.Put(u.ID, p.resize(body, p.ResizeLimit)) // put returns avatar base name, like 123456.image
avatarID, err := p.Store.Put(u.ID, resized) // put returns avatar base name, like 123456.image
if err != nil {
return "", err
}
p.Logf("[DEBUG] saved avatar from %s to %s, user %q", u.Picture, avatarID, u.Name)
p.Logf("[DEBUG] saved avatar from %s to %s, user %q", redactAvatarURL(u.Picture), avatarID, u.Name)
return p.URL + p.RoutePath + "/" + avatarID, nil
}
// load avatar from remote url and return body. Caller has to close the reader
func (p *Proxy) load(url string, client *http.Client) (rc io.ReadCloser, err error) {
// load avatar from remote location
// PutContent stores already-fetched avatar bytes via the underlying Store and returns
// the proxied URL. It exists so providers that authenticate with credentials embedded
// in the upstream URL (e.g. Telegram bot file API: /file/bot{TOKEN}/...) can fetch the
// content themselves and avoid exposing the credential to Put's URL-fetching path —
// where it would land in u.Picture, debug logs, and the user JSON returned to clients.
//
// Bytes are read into memory bounded by maxAvatarFetchSize so an unbounded caller
// (e.g. a streaming HTTP body) cannot exhaust process memory.
func (p *Proxy) PutContent(userID string, content io.Reader) (avatarURL string, err error) {
body, err := io.ReadAll(io.LimitReader(content, maxAvatarFetchSize+1))
if err != nil {
return "", fmt.Errorf("failed to read avatar content for %s: %w", userID, err)
}
if int64(len(body)) > maxAvatarFetchSize {
return "", fmt.Errorf("avatar content for %s exceeds %d bytes", userID, maxAvatarFetchSize)
}
resized := p.resize(body, p.ResizeLimit)
if resized == nil {
return "", fmt.Errorf("avatar content for %s is not a valid image", userID)
}
avatarID, err := p.Store.Put(userID, resized)
if err != nil {
return "", err
}
p.Logf("[DEBUG] saved avatar bytes to %s, user %q", avatarID, userID)
return p.URL + p.RoutePath + "/" + avatarID, nil
}
// redactAvatarURL returns the hostname only, dropping scheme, userinfo, path,
// query and fragment. This is enough to keep avatar URLs identifiable in logs
// while ensuring credentials carried in any of those parts (e.g. Telegram bot
// tokens, time-limited signed-URL tokens, basic-auth in userinfo) don't reach
// log destinations. On parse failure a sentinel is returned.
func redactAvatarURL(raw string) string {
if u, err := url.Parse(raw); err == nil && u.Hostname() != "" {
return u.Hostname()
}
return "<unparseable>"
}
// load fetches an avatar from a remote url and returns the body bytes, capped at
// maxAvatarFetchSize. The bytes are passed straight to resize without an
// intermediate Reader wrapper so we don't pay for buffering twice.
func (p *Proxy) load(url string, client *http.Client) ([]byte, error) {
var resp *http.Response
err = retry(5, time.Second, func() error {
err := retry(5, time.Second, func() error {
var e error
resp, e = client.Get(url)
return e
@@ -92,19 +150,31 @@ func (p *Proxy) load(url string, client *http.Client) (rc io.ReadCloser, err err
if err != nil {
return nil, fmt.Errorf("failed to fetch avatar from the orig: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
_ = resp.Body.Close() // caller won't close on error
return nil, fmt.Errorf("failed to get avatar from the orig, status %s", resp.Status)
}
return resp.Body, nil
// buffer the body up to the cap to fail fast on oversized inputs.
// Reading +1 byte beyond the cap distinguishes "exactly cap" from "too big".
body, err := io.ReadAll(io.LimitReader(resp.Body, maxAvatarFetchSize+1))
if err != nil {
return nil, fmt.Errorf("failed to read avatar body: %w", err)
}
if int64(len(body)) > maxAvatarFetchSize {
return nil, fmt.Errorf("avatar body exceeds %d bytes", maxAvatarFetchSize)
}
return body, nil
}
// Handler returns token routes for given provider
func (p *Proxy) Handler(w http.ResponseWriter, r *http.Request) {
setAvatarDefenseHeaders(w)
if r.Method != "GET" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
elems := strings.Split(r.URL.Path, "/")
avatarID := elems[len(elems)-1]
@@ -113,19 +183,6 @@ func (p *Proxy) Handler(w http.ResponseWriter, r *http.Request) {
return
}
// enforce client-side caching
etag := `"` + p.Store.ID(avatarID) + `"`
w.Header().Set("Etag", etag)
w.Header().Set("Cache-Control", "max-age=604800") // 7 days
if match := r.Header.Get("If-None-Match"); match != "" {
etag = strings.TrimPrefix(etag, `"`)
etag = strings.TrimSuffix(etag, `"`)
if match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
}
avReader, size, err := p.Store.Get(avatarID)
if err != nil {
rest.SendErrorJSON(w, r, p.L, http.StatusBadRequest, err, "can't load avatar")
@@ -138,18 +195,47 @@ func (p *Proxy) Handler(w http.ResponseWriter, r *http.Request) {
}
}()
// io.ReadFull keeps reading until the buffer is full or EOF, so a Store
// implementation that returns a buffered reader with a small first-Read size
// won't cause DetectContentType to misclassify a real image. Short bodies
// (avatars under sniffLen) return ErrUnexpectedEOF — that's expected, we sniff
// what we got.
buf := make([]byte, sniffLen)
n, err := avReader.Read(buf)
if err != nil && err != io.EOF {
n, err := io.ReadFull(avReader, buf)
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
p.Logf("[WARN] can't read from avatar reader for %s, %s", avatarID, err)
rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "can't read avatar")
return
}
w.Header().Set("Content-Length", strconv.Itoa(size))
contentType := http.DetectContentType(buf)
if contentType == "application/octet-stream" {
// validate the bytes really are an image before declaring a content type. Even
// though Put() now refuses to store non-image content, this catches stores poisoned
// before the fix and any future regression — never trust the bytes at the store
// boundary alone, validate again at serve time. An empty body (e.g. NoOp store)
// is treated as a benign no-content case: nothing to render, no XSS surface.
var contentType string
if n > 0 {
var ctErr error
contentType, ctErr = safeImgContentType(buf[:n])
if ctErr != nil {
p.Logf("[WARN] rejecting non-image avatar %s: %v", avatarID, ctErr)
rest.SendErrorJSON(w, r, p.L, http.StatusUnsupportedMediaType, ctErr, "invalid avatar content")
return
}
} else {
contentType = "image/*"
}
// caching headers only after validation so error responses aren't cached
etag := `"` + p.Store.ID(avatarID) + `"`
w.Header().Set("Etag", etag)
w.Header().Set("Cache-Control", "max-age=604800") // 7 days
if match := r.Header.Get("If-None-Match"); match != "" && etagMatches(match, etag) {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Length", strconv.Itoa(size))
w.Header().Set("Content-Type", contentType)
w.WriteHeader(http.StatusOK)
if _, err = w.Write(buf[:n]); err != nil {
@@ -162,33 +248,57 @@ func (p *Proxy) Handler(w http.ResponseWriter, r *http.Request) {
}
}
// resize an image of supported format (PNG, JPG, GIF) to the size of "limit" px of the biggest side
// (width or height) preserving aspect ratio.
// Returns original reader if resizing is not needed or failed.
func (p *Proxy) resize(reader io.Reader, limit int) io.Reader {
if reader == nil {
p.Logf("[WARN] avatar resize(): reader is nil")
// resize validates that the input is a real image and, if needed, re-encodes it to
// fit within "limit" px on the larger side preserving aspect ratio. Returns nil for
// non-image content or for declared dimensions exceeding maxAvatarPixels so attacker
// payloads (HTML/SVG/decompression bombs) never reach the store. With limit <= 0 or
// when the image already fits, the original bytes are returned verbatim so animated
// GIFs and other multi-frame formats round-trip without being flattened to one frame.
//
// Validation uses image.DecodeConfig (cheap — declares dimensions, allocates nothing)
// before any full image.Decode, so a 100 KB compressed image declaring 65535x65535 px
// is rejected without ever materializing the raster.
//
// Callers (load, PutContent, identicon generation) must ensure body is bounded by
// maxAvatarFetchSize before calling; resize trusts the size invariant rather than
// re-buffering. An empty body or a body over the cap is refused defensively.
func (p *Proxy) resize(body []byte, limit int) io.Reader {
if len(body) == 0 || int64(len(body)) > maxAvatarFetchSize {
p.Logf("[WARN] avatar resize(): refusing body of size %d (cap %d)", len(body), maxAvatarFetchSize)
return nil
}
if limit <= 0 {
p.Logf("[DEBUG] avatar resize(): limit should be greater than 0")
return reader
}
var teeBuf bytes.Buffer
tee := io.TeeReader(reader, &teeBuf)
src, _, err := image.Decode(tee)
// validate format and dimensions without allocating pixel memory.
cfg, _, err := image.DecodeConfig(bytes.NewReader(body))
if err != nil {
// non-image input must never reach the store: refuse and let the caller
// fall back to an identicon. Returning the raw bytes here previously let
// an attacker who controlled u.Picture poison the store with HTML/SVG that
// the Handler would later serve back with text/html content type.
p.Logf("[WARN] avatar resize(): can't decode avatar image, %s", err)
return &teeBuf
return nil
}
// multiply in int64 — on 32-bit builds (GOARCH=386, 32-bit arm) the int
// product of two 16-bit-or-larger dimensions can overflow and wrap below
// maxAvatarPixels, bypassing the cap. GIF's 16-bit logical screen and
// JPEG's 16-bit SOF dimensions both hit this if multiplied as int32.
if cfg.Width <= 0 || cfg.Height <= 0 || int64(cfg.Width)*int64(cfg.Height) > int64(maxAvatarPixels) {
p.Logf("[WARN] avatar resize(): declared dimensions %dx%d exceed safe limit", cfg.Width, cfg.Height)
return nil
}
bounds := src.Bounds()
w, h := bounds.Dx(), bounds.Dy()
if w <= limit && h <= limit || w <= 0 || h <= 0 {
p.Logf("[DEBUG] resizing image is smaller that the limit or has 0 size")
return &teeBuf
if limit <= 0 || (cfg.Width <= limit && cfg.Height <= limit) {
p.Logf("[DEBUG] avatar resize(): no resize needed (dim %dx%d, limit %d)", cfg.Width, cfg.Height, limit)
return bytes.NewReader(body)
}
// dimensions are bounded — full decode is now safe to allocate.
src, _, err := image.Decode(bytes.NewReader(body))
if err != nil {
p.Logf("[WARN] avatar resize(): decode after dim-check failed, %s", err)
return nil
}
w, h := src.Bounds().Dx(), src.Bounds().Dy()
newW, newH := w*limit/h, limit
if w > h {
newW, newH = limit, h*limit/w
@@ -200,11 +310,64 @@ func (p *Proxy) resize(reader io.Reader, limit int) io.Reader {
var out bytes.Buffer
if err = png.Encode(&out, m); err != nil {
p.Logf("[WARN] avatar resize(): can't encode resized avatar to PNG, %s", err)
return &teeBuf
return bytes.NewReader(body) // fall back to the validated original
}
return &out
}
// safeImgContentType returns the sniffed content type if the bytes look like a safe
// raster image format. The set is an explicit allowlist (PNG, JPEG, GIF, WebP, BMP,
// ICO) — no HasPrefix("image/") catch-all — so future scriptable image/* MIME types
// added by http.DetectContentType cannot silently pass. Returns an error otherwise.
// image/svg+xml is excluded because SVG can execute scripts when navigated to top
// level; image/* coverage of icon files uses both spellings http.DetectContentType
// is known to return.
func safeImgContentType(img []byte) (string, error) {
ct := http.DetectContentType(img)
base := ct
if idx := strings.Index(base, ";"); idx >= 0 {
base = strings.TrimSpace(base[:idx])
}
switch base {
case "image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp",
"image/x-icon", "image/vnd.microsoft.icon":
return base, nil
}
return "", fmt.Errorf("non-image content type %q", ct)
}
// etagMatches reports whether the If-None-Match header value matches the response
// ETag per RFC 7232: the header is a comma-separated list of opaque-tags (each in
// double quotes), optionally weak-prefixed with W/. The wildcard "*" matches anything.
// We deliberately ignore weak/strong distinction because avatar responses are static
// per id — both forms identify the same resource.
func etagMatches(header, etag string) bool {
header = strings.TrimSpace(header)
if header == "*" {
return true
}
for tag := range strings.SplitSeq(header, ",") {
tag = strings.TrimSpace(tag)
tag = strings.TrimPrefix(tag, "W/")
if tag == etag {
return true
}
}
return false
}
// setAvatarDefenseHeaders applies layered defense headers on every avatar response
// (success, 304, or error). Each header survives content-type validation regressions,
// browser sniffing, and top-level navigation:
// - Content-Security-Policy: strict, with sandbox — blocks inline scripts/handlers
// - X-Content-Type-Options: nosniff — prevents MIME-overriding the declared type
// - Content-Disposition: inline; filename="avatar" — frames the response as a file
func setAvatarDefenseHeaders(w http.ResponseWriter) {
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox; frame-ancestors 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Disposition", `inline; filename="avatar"`)
}
// GenerateAvatar for give user with identicon
func GenerateAvatar(user string) ([]byte, error) {
@@ -243,7 +406,7 @@ func GetGravatarURL(email string) (res string, err error) {
}
func retry(retries int, delay time.Duration, fn func() error) (err error) {
for i := 0; i < retries; i++ {
for range retries {
if err = fn(); err == nil {
return nil
}
+5 -5
View File
@@ -6,17 +6,17 @@ import "log"
// L defined logger interface used everywhere in the package
type L interface {
Logf(format string, args ...interface{})
Logf(format string, args ...any)
}
// Func type is an adapter to allow the use of ordinary functions as Logger.
type Func func(format string, args ...interface{})
type Func func(format string, args ...any)
// Logf calls f(id)
func (f Func) Logf(format string, args ...interface{}) { f(format, args...) }
func (f Func) Logf(format string, args ...any) { f(format, args...) }
// NoOp logger
var NoOp = Func(func(string, ...interface{}) {})
var NoOp = Func(func(string, ...any) {})
// Std logger sends to std default logger directly
var Std = Func(func(format string, args ...interface{}) { log.Printf(format, args...) })
var Std = Func(func(format string, args ...any) { log.Printf(format, args...) })
+2 -2
View File
@@ -57,7 +57,7 @@ type ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, statusCode in
var adminUser = token.User{
ID: "admin",
Name: "admin",
Attributes: map[string]interface{}{
Attributes: map[string]any{
"admin": true,
},
}
@@ -243,7 +243,7 @@ func (a *Authenticator) basicAdminUser(r *http.Request) bool {
// using ConstantTimeCompare to avoid timing attack
if user != "admin" || subtle.ConstantTimeCompare([]byte(passwd), []byte(a.AdminPasswd)) != 1 {
a.Logf("[WARN] admin basic auth failed, user/passwd mismatch, %s:%s", user, passwd)
a.Logf("[WARN] admin basic auth failed for user %q", user)
return false
}
+41 -5
View File
@@ -13,6 +13,7 @@ import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
@@ -76,7 +77,7 @@ type AppleConfig struct {
ResponseMode string // changes method of receiving data in callback. Default value "form_post" (https://developer.apple.com/documentation/sign_in_with_apple/request_an_authorization_to_the_sign_in_with_apple_server?changes=_1_2#4066168)
scopes []string // for this package allow only username scope and UID in token claims. Apple service API provide only "email" and "name" scope values (https://developer.apple.com/documentation/sign_in_with_apple/clientconfigi/3230955-scope)
privateKey interface{} // private key from Apple obtained in developer account (the keys section). Required for create the Client Secret (https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens#3262048)
privateKey any // private key from Apple obtained in developer account (the keys section). Required for create the Client Secret (https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens#3262048)
publicKey crypto.PublicKey // need for validate sign of token
clientSecret string // is the JWT client secret will create after first call and then used until expired
jwkURL string // URL for fetch JWK Apple keys, need redefine for tests
@@ -228,7 +229,7 @@ func (ah *AppleHandler) initPrivateKey() error {
}
// tokenKeyFunc use for verify JWT sign, it receives the parsed token and should return the key for validating.
func (ah *AppleHandler) tokenKeyFunc(jwtToken *jwt.Token) (interface{}, error) {
func (ah *AppleHandler) tokenKeyFunc(jwtToken *jwt.Token) (any, error) {
if jwtToken == nil {
return nil, fmt.Errorf("failed to call token keyFunc, because token is nil")
}
@@ -331,7 +332,7 @@ func (ah AppleHandler) AuthHandler(w http.ResponseWriter, r *http.Request) {
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "exchange failed")
return
}
ah.Logf("[DEBUG] response data %+v", resp)
ah.Logf("[DEBUG] apple exchange response: %s", appleVerificationResponseLogSummary(resp))
if resp.Error != "" {
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, nil, fmt.Sprintf("fetch IDtoken response error: %s", resp.Error))
return
@@ -345,10 +346,22 @@ func (ah AppleHandler) AuthHandler(w http.ResponseWriter, r *http.Request) {
return
}
// get token claims for extract uid (and email or name if they exist in scope)
// get token claims for extract uid (and email or name if they exist in scope).
// jwt v5 parser options enforce iss == https://appleid.apple.com and
// aud == ClientID inline so we don't need a separate validate pass.
tokenClaims := jwt.MapClaims{}
_, err = jwt.ParseWithClaims(resp.IDToken, tokenClaims, keySet.keyFunc)
_, err = jwt.ParseWithClaims(resp.IDToken, tokenClaims, keySet.keyFunc,
jwt.WithIssuer(appleIDTokenIssuer),
jwt.WithAudience(ah.conf.ClientID))
if err != nil {
// distinguish a confused-deputy reject (iss/aud) from a server-side
// parse/sig failure so the handler returns the same 403 + body as
// before for the security-relevant case.
if errors.Is(err, jwt.ErrTokenInvalidIssuer) || errors.Is(err, jwt.ErrTokenInvalidAudience) {
ah.Logf("[WARN] apple id_token rejected: %s", err.Error())
rest.SendErrorJSON(w, r, ah.L, http.StatusForbidden, nil, "invalid id_token")
return
}
ah.Logf("[ERROR] failed to get claims: " + err.Error())
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, nil, fmt.Sprintf("failed to token validation, key is invalid: %s", resp.Error))
return
@@ -549,3 +562,26 @@ func (ah AppleHandler) makeRedirURL(path string) string {
return strings.TrimRight(ah.URL, "/") + strings.TrimSuffix(newPath, "/") + urlCallbackSuffix
}
// appleVerificationResponseLogSummary formats appleVerificationResponse for safe
// logging. The struct's AccessToken, RefreshToken and IDToken fields are
// credentials and must never appear verbatim in logs that may be shipped to
// centralized logging or third-party observability systems; this helper logs
// only their presence (present|missing), the non-secret token type and
// expiry, plus any provider-side error string.
func appleVerificationResponseLogSummary(r appleVerificationResponse) string {
return fmt.Sprintf("type=%s expires_in=%d access_token=%s refresh_token=%s id_token=%s error=%q",
r.TokenType, r.ExpiresIn,
presence(r.AccessToken), presence(r.RefreshToken), presence(r.IDToken), r.Error)
}
func presence(s string) string {
if s == "" {
return "missing"
}
return "present"
}
// appleIDTokenIssuer is the issuer Apple sets on every id_token issued by Sign in with Apple.
// see https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/verifying_a_user
const appleIDTokenIssuer = "https://appleid.apple.com" // #nosec G101 -- public Apple issuer URL, not a credential
+1 -1
View File
@@ -162,7 +162,7 @@ func (aks *appleKeySet) get(kid string) (keys *applePublicKey, err error) {
}
// keyFunc use for JWT verify with specific public key
func (aks *appleKeySet) keyFunc(token *jwt.Token) (interface{}, error) {
func (aks *appleKeySet) keyFunc(token *jwt.Token) (any, error) {
keyID, ok := token.Header["kid"].(string)
if !ok {
+10 -2
View File
@@ -79,17 +79,25 @@ func (c *CustomServer) Run(ctx context.Context) {
u, err := url.Parse(c.URL)
if err != nil {
c.Logf("[ERROR] failed to parse service base URL=%s", c.URL)
c.lock.Unlock()
return
}
_, port, err := net.SplitHostPort(u.Host)
host, port, err := net.SplitHostPort(u.Host)
if err != nil {
c.Logf("[ERROR] failed to get port from URL=%s", c.URL)
c.lock.Unlock()
return
}
// hostname from URL is honored; only an explicit non-loopback host
// (e.g. "0.0.0.0" baked into c.URL) binds beyond loopback. Empty/
// "localhost" falls through to localBindAddr's 127.0.0.1 default.
if host == "localhost" {
host = ""
}
c.httpServer = &http.Server{
Addr: fmt.Sprintf(":%s", port),
Addr: localBindAddr(host, port),
ReadHeaderTimeout: 5 * time.Second,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
+1 -1
View File
@@ -57,7 +57,7 @@ func (d *DevAuthServer) Run(ctx context.Context) { // nolint (gocyclo)
}
d.httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", d.Provider.Port),
Addr: localBindAddr(d.Provider.Host, fmt.Sprintf("%d", d.Provider.Port)),
ReadHeaderTimeout: 5 * time.Second,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
d.Logf("[DEBUG] dev oauth request %s %s %+v", r.Method, r.URL, r.Header)
+1 -1
View File
@@ -122,7 +122,7 @@ func (h Oauth1Handler) AuthHandler(w http.ResponseWriter, r *http.Request) {
return
}
jData := map[string]interface{}{}
jData := map[string]any{}
if e := json.Unmarshal(data, &jData); e != nil {
rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to unmarshal user info")
return
+2 -2
View File
@@ -57,7 +57,7 @@ type Params struct {
}
// UserData is type for user information returned from oauth2 providers /info API method
type UserData map[string]interface{}
type UserData map[string]any
// Value returns value for key or empty string if not found
func (u UserData) Value(key string) string {
@@ -197,7 +197,7 @@ func (p Oauth2Handler) AuthHandler(w http.ResponseWriter, r *http.Request) {
return
}
jData := map[string]interface{}{}
jData := map[string]any{}
if e := json.Unmarshal(data, &jData); e != nil {
rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to unmarshal user info")
return
+6 -2
View File
@@ -79,9 +79,13 @@ func NewEmailClient(emailParams EmailParams, l logger.L) *Email {
return &Email{EmailParams: emailParams, L: l, sender: sender}
}
// Send email with given text
// Send email with given text. The body is not logged: confirmation emails
// sent by the verify provider contain a one-shot magic-link token, and any
// party with log access could redeem it before the user does. Logging only
// the recipient and body length keeps the line useful for operators
// without leaking the credential.
func (e *Email) Send(to, text string) error {
e.Logf("[DEBUG] send %q to %s", text, to)
e.Logf("[DEBUG] send %d-byte message to %s", len(text), to)
return e.sender.Send(text, email.Params{
From: e.From,
To: []string{to},
+15
View File
@@ -4,6 +4,7 @@ import (
"crypto/rand"
"crypto/sha1"
"fmt"
"net"
"net/http"
"strings"
@@ -11,6 +12,20 @@ import (
"github.com/go-pkgz/auth/v2/token"
)
// localBindAddr returns the listen address for the dev oauth and custom-server
// helpers. Both servers are intended for local development and embedded
// flows; the historical default ":port" listened on every interface, which
// silently exposed the dev OAuth UI to anyone on the LAN. Default the bind
// to 127.0.0.1; callers that explicitly want a non-loopback bind can pass
// a hostname (e.g. "0.0.0.0" or a specific IP) via Provider.Host (dev) or
// the URL host (custom-server).
func localBindAddr(host, port string) string {
if host == "" {
host = "127.0.0.1"
}
return net.JoinHostPort(host, port)
}
const (
urlLoginSuffix = "/login"
urlCallbackSuffix = "/callback"
+126 -8
View File
@@ -3,13 +3,16 @@ package provider
//go:generate moq --out telegram_moq_test.go . TelegramAPI
import (
"bytes"
"context"
"crypto/sha1"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
neturl "net/url"
"regexp"
"strings"
"sync"
"sync/atomic"
@@ -19,6 +22,7 @@ import (
"github.com/go-pkgz/rest"
"github.com/golang-jwt/jwt/v5"
"github.com/go-pkgz/auth/v2/avatar"
"github.com/go-pkgz/auth/v2/logger"
authtoken "github.com/go-pkgz/auth/v2/token"
)
@@ -186,11 +190,18 @@ func (th *TelegramHandler) processUpdates(ctx context.Context, updates *telegram
id := th.ProviderName + "_" + authtoken.HashID(sha1.New(), fmt.Sprint(update.Message.Chat.ID))
// avatarURL embeds the bot token in its path
// (https://api.telegram.org/file/bot{TOKEN}/...). Never store it in
// User.Picture: it would leak through avatar.Proxy.Put logs and, when
// no avatar saver is configured, into the JWT and on to the client.
// Fetch the bytes here and hand them to the avatar store directly.
picture := th.saveTelegramAvatar(ctx, id, avatarURL)
authRequest.confirmed = true
authRequest.user = &authtoken.User{
ID: id,
Name: update.Message.Chat.Name,
Picture: avatarURL,
Picture: picture,
}
th.requests.Lock()
@@ -294,10 +305,19 @@ func (th *TelegramHandler) LoginHandler(w http.ResponseWriter, r *http.Request)
return
}
u, err := setAvatar(th.AvatarSaver, *authUser, &http.Client{Timeout: 5 * time.Second})
if err != nil {
rest.SendErrorJSON(w, r, th.L, http.StatusInternalServerError, err, "failed to save avatar to proxy")
return
// when saveTelegramAvatar already populated Picture with a local proxy
// URL, skip the URL-fetching avatar pipeline. Letting setAvatar run
// here would have it call Proxy.Put which re-fetches Picture; in
// split-DNS / unreachable-internal-Opts.URL deployments that fetch
// fails and the identicon fallback would silently overwrite the
// stored Telegram bytes with an identicon at the same store path.
u := *authUser
if u.Picture == "" {
u, err = setAvatar(th.AvatarSaver, *authUser, &http.Client{Timeout: 5 * time.Second})
if err != nil {
rest.SendErrorJSON(w, r, th.L, http.StatusInternalServerError, err, "failed to save avatar to proxy")
return
}
}
claims := authtoken.Claims{
@@ -449,18 +469,18 @@ func (tg *tgAPI) BotInfo(ctx context.Context) (*botInfo, error) {
return resp.Result, nil
}
func (tg *tgAPI) request(ctx context.Context, method string, data interface{}) error {
func (tg *tgAPI) request(ctx context.Context, method string, data any) error {
return repeater.NewFixed(3, time.Millisecond*50).Do(ctx, func() error {
url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", tg.token, method)
req, err := http.NewRequestWithContext(ctx, "GET", url, http.NoBody)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
return fmt.Errorf("failed to create request: %w", redactBotURLInErr(err))
}
resp, err := tg.client.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
return fmt.Errorf("failed to send request: %w", redactBotURLInErr(err))
}
defer resp.Body.Close() //nolint gosec // we don't care about response body
@@ -485,3 +505,101 @@ func (tg *tgAPI) parseError(r io.Reader, statusCode int) error {
}
return fmt.Errorf("unexpected telegram API status code %d, error: %q", statusCode, tgErr.Description)
}
// avatarContentSaver matches the optional method on AvatarSaver implementations
// that can store already-fetched bytes (avatar.Proxy provides one). Used by the
// Telegram provider to avoid passing a bot-token-bearing URL through the
// URL-fetching avatar pipeline.
type avatarContentSaver interface {
PutContent(userID string, content io.Reader) (string, error)
}
// saveTelegramAvatar fetches the avatar bytes from a bot-token-bearing Telegram
// URL and stores them via th.AvatarSaver, returning a clean local proxy URL.
// The bot URL is consumed entirely inside this function so it never reaches
// User.Picture, JWT claims, or any debug log of the user object. Returns ""
// when the avatar cannot be saved (no URL, no compatible saver, or fetch
// failure) — the caller treats that as "no picture" and the avatar pipeline
// falls back to identicon as usual.
func (th *TelegramHandler) saveTelegramAvatar(ctx context.Context, userID, avatarURL string) string {
if avatarURL == "" {
return ""
}
// guard against typed-nil *avatar.Proxy. auth.go skips initializing
// res.avatarProxy when Opts.AvatarStore is unset, so AvatarSaver can be
// a non-nil interface wrapping a nil *avatar.Proxy. The type assertion
// below would still succeed (interface satisfaction is structural), but
// PutContent on a nil receiver panics on the first p.Store deref.
if th.AvatarSaver == nil || th.AvatarSaver == (*avatar.Proxy)(nil) {
th.Logf("[WARN] telegram avatar dropped: AvatarSaver is not configured")
return ""
}
saver, ok := th.AvatarSaver.(avatarContentSaver)
if !ok {
// fallback intentionally drops the picture rather than expose the bot
// token; warn so operators can wire a content-aware saver if they want
// telegram avatars saved
th.Logf("[WARN] telegram avatar dropped: configured AvatarSaver does not support direct content save")
return ""
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, avatarURL, http.NoBody)
if err != nil {
th.Logf("[WARN] telegram avatar fetch request build failed: %v", redactBotURLInErr(err))
return ""
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
th.Logf("[WARN] telegram avatar fetch failed: %v", redactBotURLInErr(err))
return ""
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
th.Logf("[WARN] telegram avatar fetch returned status %d", resp.StatusCode)
return ""
}
// cap body size to protect PutContent from an unbounded upstream response.
// Telegram caps photos at 5 MiB; 10 MiB is generous headroom while still
// bounding worst-case memory.
body, err := io.ReadAll(io.LimitReader(resp.Body, maxTelegramAvatarSize+1))
if err != nil {
th.Logf("[WARN] telegram avatar read failed: %v", err)
return ""
}
if int64(len(body)) > maxTelegramAvatarSize {
th.Logf("[WARN] telegram avatar dropped: body exceeds %d bytes", maxTelegramAvatarSize)
return ""
}
picture, err := saver.PutContent(userID, bytes.NewReader(body))
if err != nil {
th.Logf("[WARN] telegram avatar save failed: %v", err)
return ""
}
return picture
}
const maxTelegramAvatarSize = 10 << 20
// botTokenInURLPath matches the bot-token segment of a Telegram URL anchored
// between path slashes ("/botTOKEN/..."). The leading and trailing slashes
// avoid matching unrelated identifiers that happen to start with "bot" (e.g.
// the username "botFather" appearing elsewhere in a log line). Replacement
// preserves the slashes via "/bot<redacted>/" to keep surrounding URL
// structure intact for diagnostics.
var botTokenInURLPath = regexp.MustCompile(`/bot[A-Za-z0-9:_-]+/`)
// redactBotURLInErr returns the error with any embedded Telegram bot-token
// segment in URL paths replaced by "bot<redacted>". net/http's *url.Error
// stringifies as `Op "URL": Err`, so a transport failure on a URL like
// https://api.telegram.org/file/bot<TOKEN>/... otherwise prints the token
// verbatim.
func redactBotURLInErr(err error) error {
if err == nil {
return nil
}
redacted := botTokenInURLPath.ReplaceAllString(err.Error(), "/bot<redacted>/")
if redacted == err.Error() {
return err
}
return errors.New(redacted)
}
+165 -1
View File
@@ -3,10 +3,13 @@ package provider
import (
"bytes"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"fmt"
"html/template"
"net/http"
"strings"
"sync"
"time"
"github.com/go-pkgz/rest"
@@ -19,6 +22,18 @@ import (
// VerifyHandler implements non-oauth2 provider authorizing users with some confirmation.
// can be email, IM or anything else implementing Sender interface
//
// Identity caveat: the local user id returned to the application is derived
// from the verified address (ProviderName + "_" + HashID(address)). The
// confirmation round-trip proves current control of the address at login
// time; it does not guarantee a stable+unique identity over time. The owner
// of an address can change without the address changing — employer
// offboarding, lapsed free-mail accounts, and recycled domains all hand
// control of an address to the next person who claims it. Integrators that
// need stable identity should map the verified address to a server-side
// immutable user id at first successful verify and key their records on
// that id, not on the value returned here. See the "Email-as-identity
// caveat" section of the README for guidance.
type VerifyHandler struct {
logger.L
ProviderName string
@@ -38,6 +53,112 @@ type VerifyHandler struct {
// here. Nil disables validation and preserves legacy permissive
// behavior — any non-empty "from" value is honored.
AllowedRedirectHosts token.AllowedHosts
// ConfirmationStore enforces one-shot consumption of confirmation tokens.
// When non-nil, a token cannot be redeemed twice within its TTL window.
// Leave nil to keep the legacy behavior (token replayable until expiry).
ConfirmationStore VerifConfirmationStore
}
// VerifConfirmationStore tracks consumed confirmation tokens to prevent replay.
// Implementations must be safe for concurrent use.
type VerifConfirmationStore interface {
// MarkUsed records key as consumed and returns alreadyUsed=true if it was
// already recorded. The implementation MUST retain the marker for at
// least the supplied ttl, or return a non-nil err if it cannot --
// dropping a marker before its ttl while the underlying JWT is still
// valid reopens the replay window the store is meant to close. err
// signals a backend failure (network, disk, capacity, etc.); callers
// MUST treat a non-nil err as fail-closed (reject the redemption).
//
// Adapter authors: do NOT embed key (or any caller-supplied data) in
// returned errors. The handler logs err on the fail-closed branch, and
// although key is the SHA-256 of the raw token rather than the token
// itself, it still uniquely identifies the live, unredeemed JWT in
// log destinations. Wrap the underlying backend error with a generic
// description (e.g. "redis SET failed: %w") instead.
MarkUsed(key string, ttl time.Duration) (alreadyUsed bool, err error)
}
// VerifConfirmationStoreFunc is an adapter to use ordinary functions as
// VerifConfirmationStore, mirroring the SenderFunc / token.AllowedHostsFunc
// house pattern for closure-based config.
type VerifConfirmationStoreFunc func(key string, ttl time.Duration) (alreadyUsed bool, err error)
// MarkUsed calls f(key, ttl) to implement VerifConfirmationStore.
func (f VerifConfirmationStoreFunc) MarkUsed(key string, ttl time.Duration) (bool, error) {
return f(key, ttl)
}
// NewInMemoryVerifStore returns a process-local default VerifConfirmationStore.
// Suitable for single-instance deployments. Multi-instance deployments behind
// a load balancer MUST supply a shared backend (e.g. Redis) -- otherwise an
// attacker who lands on a different instance from the legitimate user can
// replay the token there. The default's failure is silent: the request
// completes normally and no log indicates the protection was bypassed.
func NewInMemoryVerifStore() VerifConfirmationStore {
return &inMemoryVerifStore{used: make(map[string]time.Time)}
}
type inMemoryVerifStore struct {
mu sync.Mutex
used map[string]time.Time // key -> expiry
insertCount int
}
// inMemoryVerifStoreSweepEvery is the in-memory store's amortization cadence.
// Walking the whole map on every redemption is O(n) under a single mutex,
// which serializes the hot path. Sweeping every N inserts keeps the map size
// bounded by ~N + (concurrent redemptions during the gap) without holding
// the lock through a full walk on most calls. Declared as a var rather than
// a const so tests can lower it to exercise the sweep branch.
var inMemoryVerifStoreSweepEvery = 256
func (s *inMemoryVerifStore) MarkUsed(key string, ttl time.Duration) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
if exp, ok := s.used[key]; ok && exp.After(now) {
return true, nil
}
// amortized eviction: walk the map only every Nth insert, not on every
// hot-path call. The lookup above already rejects unexpired duplicates,
// so worst-case staleness is bounded by N inserts between sweeps.
s.insertCount++
if s.insertCount >= inMemoryVerifStoreSweepEvery {
s.insertCount = 0
for k, exp := range s.used {
if !exp.After(now) {
delete(s.used, k)
}
}
}
s.used[key] = now.Add(ttl)
return false, nil
}
// confirmationKey hashes the raw token so the store key length is bounded
// regardless of token size, and so the in-memory map doesn't retain the
// signed token itself.
func confirmationKey(rawToken string) string {
sum := sha256.Sum256([]byte(rawToken))
return hex.EncodeToString(sum[:])
}
// scrubTokenFromRequest returns a shallow clone of r with the "token" query
// parameter replaced by "<redacted>". rest.SendErrorJSON logs r.URL, and the
// fail-closed branches in LoginHandler fire while the confirmation JWT is
// still live (store didn't record consumption) -- a single log line equals
// an unredeemed magic link without this scrub.
func scrubTokenFromRequest(r *http.Request) *http.Request {
if r == nil || r.URL == nil || r.URL.Query().Get("token") == "" {
return r
}
rc := r.Clone(r.Context())
q := rc.URL.Query()
q.Set("token", "<redacted>")
rc.URL.RawQuery = q.Encode()
return rc
}
// Sender defines interface to send emails
@@ -66,7 +187,13 @@ type VerifTokenService interface {
func (e VerifyHandler) Name() string { return e.ProviderName }
// LoginHandler gets name and address from query, makes confirmation token and sends it to user.
// In case if confirmation token presented in the query uses it to create auth token
// In case if confirmation token presented in the query uses it to create auth token.
//
// Consumption is final when ConfirmationStore is configured: the token is
// marked used before any further side effects (avatar fetch, token issuance),
// so a transient downstream failure burns the token and the user must request
// a new confirmation email rather than retry the same link. This trade-off
// keeps the replay check atomic with the security boundary.
func (e VerifyHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
// GET /login?site=site&user=name&address=someone@example.com
@@ -89,6 +216,36 @@ func (e VerifyHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
return
}
store := e.ConfirmationStore
// guard against a typed-nil VerifConfirmationStoreFunc: a non-nil
// interface wrapping a nil func survives the != nil check above and
// would panic at MarkUsed. Treat it as no store configured. Mirrors
// the AllowedHostsFunc nil-guard in token/jwt.go.
if fn, ok := store.(VerifConfirmationStoreFunc); ok && fn == nil {
store = nil
}
if store != nil {
ttl := time.Minute
if confClaims.ExpiresAt != nil {
if remaining := time.Until(confClaims.ExpiresAt.Time); remaining > 0 {
ttl = remaining
}
}
alreadyUsed, markErr := store.MarkUsed(confirmationKey(tkn), ttl)
if markErr != nil {
// fail-closed: a backend outage must not let attackers replay
// tokens. Reject with the token scrubbed from the logged URL,
// since on this branch the store did NOT record consumption so
// the JWT in the URL is still live.
rest.SendErrorJSON(w, scrubTokenFromRequest(r), e.L, http.StatusForbidden, markErr, "confirmation token store unavailable")
return
}
if alreadyUsed {
rest.SendErrorJSON(w, scrubTokenFromRequest(r), e.L, http.StatusForbidden, fmt.Errorf("token already used"), "confirmation token already consumed")
return
}
}
elems := strings.Split(confClaims.Handshake.ID, "::")
if len(elems) != 2 {
rest.SendErrorJSON(w, r, e.L, http.StatusBadRequest, fmt.Errorf("%s", confClaims.Handshake.ID), "invalid handshake token")
@@ -162,6 +319,13 @@ func (e VerifyHandler) sendConfirmation(w http.ResponseWriter, r *http.Request)
Handshake: &token.Handshake{
State: "",
ID: user + "::" + address,
// without copying "from" here the redirect validator at the
// other end has nothing to validate or to redirect to. The
// docs (and #275) advertise ?from=<url> on the verify login
// path, but the original sendConfirmation never put it on
// the handshake JWT, so production verify flows could never
// honor from at all.
From: r.URL.Query().Get("from"),
},
SessionOnly: r.URL.Query().Get("session") != "" && r.URL.Query().Get("session") != "0",
RegisteredClaims: jwt.RegisteredClaims{
+7 -7
View File
@@ -28,16 +28,16 @@ type User struct {
Audience string `json:"aud,omitempty"`
// set by client
IP string `json:"ip,omitempty"`
Email string `json:"email,omitempty"`
Attributes map[string]interface{} `json:"attrs,omitempty"`
Role string `json:"role,omitempty"`
IP string `json:"ip,omitempty"`
Email string `json:"email,omitempty"`
Attributes map[string]any `json:"attrs,omitempty"`
Role string `json:"role,omitempty"`
}
// SetBoolAttr sets boolean attribute
func (u *User) SetBoolAttr(key string, val bool) {
if u.Attributes == nil {
u.Attributes = map[string]interface{}{}
u.Attributes = map[string]any{}
}
u.Attributes[key] = val
}
@@ -45,7 +45,7 @@ func (u *User) SetBoolAttr(key string, val bool) {
// SetStrAttr sets string attribute
func (u *User) SetStrAttr(key, val string) {
if u.Attributes == nil {
u.Attributes = map[string]interface{}{}
u.Attributes = map[string]any{}
}
u.Attributes[key] = val
}
@@ -100,7 +100,7 @@ func (u *User) SliceAttr(key string) []string {
// SetSliceAttr sets slice attribute for given key
func (u *User) SetSliceAttr(key string, val []string) {
if u.Attributes == nil {
u.Attributes = map[string]interface{}{}
u.Attributes = map[string]any{}
}
u.Attributes[key] = val
}