textmachine/platform/internal/login/store.go

109 lines
3.6 KiB
Go

package login
import (
"context"
"net"
"strings"
"time"
"unicode"
)
// State is one authorization round trip, held server-side. The state itself is stored as a digest:
// it travels in a URL and in a cookie, so it is a credential like any other.
type State struct {
Hash []byte
// Provider is OUR nickname for the issuer: it names which configuration the callback loads.
Provider string
// Issuer is the identifier of the authorization server this request was SENT to, kept so the
// callback can compare it with what came back. RFC 9700 §4.4.2 makes storing it the
// prerequisite of either mix-up defence; the binding to the user agent is the state cookie.
Issuer string
Nonce string
Verifier string
ReturnTo string
// StartID is the request id of the leg that created this state, so the log can join the two
// halves of one sign-in without naming the user.
StartID string
CreatedAt time.Time
ExpiresAt time.Time
}
// Identity is what the provider proved. Subject is the key; the address is a hint.
type Identity struct {
Provider string
Subject string
Email string
EmailVerified bool
}
// LoginEvent is one line of the journal.
type LoginEvent struct {
UserID string // empty when the attempt never reached an account
Provider string
Outcome string // success | denied
Reason string
IPPrefix string
Client string
At time.Time
}
// Store is the persistence the flow needs. It is an interface so the flow is testable without a
// database and so the SQL stays in one package.
type Store interface {
PutLoginState(ctx context.Context, s State) error
// TakeLoginState consumes the state: a second callback with the same one must fail. Expiry is a
// clause of the query, not a check the caller could forget.
TakeLoginState(ctx context.Context, hash []byte, now time.Time) (State, error)
// UpsertIdentity resolves (provider, subject) to a user, creating the account — and writing its
// signup grant in the SAME transaction — when the pair is new.
UpsertIdentity(ctx context.Context, in Identity, now time.Time, signupGrantMicroUSD int64) (userID string, err error)
CreateSession(ctx context.Context, digest []byte, userID string, now time.Time, idleTTL, maxAge time.Duration) error
RevokeSession(ctx context.Context, digest []byte, now time.Time) error
RevokeUserSessions(ctx context.Context, userID string, now time.Time) (int64, error)
RecordLogin(ctx context.Context, ev LoginEvent) error
}
// ipPrefix truncates an address to a network: /24 for IPv4, /48 for IPv6. The journal answers
// "roughly where from", and a full address would make it a tracking database of its own.
func ipPrefix(remoteAddr string) string {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
ip := net.ParseIP(host)
if ip == nil {
return ""
}
if v4 := ip.To4(); v4 != nil {
return v4.Mask(net.CIDRMask(24, 32)).String() + "/24"
}
return ip.Mask(net.CIDRMask(48, 128)).String() + "/48"
}
// clientClass reduces a user agent to a word. Storing the string itself would be a fingerprint;
// the question a user asks is "was that my browser or my desktop app".
func clientClass(ua string) string {
switch {
case ua == "":
return "unknown"
case strings.Contains(ua, "tmctl") || strings.Contains(ua, "textmachine"):
return "desktop"
case strings.Contains(ua, "Mozilla"):
return "browser"
default:
return "other"
}
}
// sanitize keeps a provider-supplied token loggable: short, printable, no control characters.
func sanitize(s string) string {
if len(s) > 40 {
s = s[:40]
}
return strings.Map(func(r rune) rune {
if unicode.IsPrint(r) && r < unicode.MaxASCII {
return r
}
return '?'
}, s)
}