109 lines
3.6 KiB
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)
|
|
}
|