@@ -0,0 +1,198 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
type PublicJWK struct {
|
||||
Kty string `json:"kty"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
Ext bool `json:"ext,omitempty"`
|
||||
KeyOps []string `json:"key_ops,omitempty"`
|
||||
// Optional standard JWK metadata. Authentication intentionally does not
|
||||
// depend on these values; accepting them keeps WebCrypto exports portable
|
||||
// across Chrome, Firefox, Safari and other standards-compliant browsers.
|
||||
Alg string `json:"alg,omitempty"`
|
||||
Use string `json:"use,omitempty"`
|
||||
Kid string `json:"kid,omitempty"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
ClientID string `json:"cid"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"sid"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
type challengeEntry struct {
|
||||
ClientID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
secret []byte
|
||||
|
||||
// Login challenges are intentionally process-local ephemeral state. They do
|
||||
// not belong in SQLite: issuing a challenge is on the hot path when many
|
||||
// browsers connect at once and must not compete with guesses/presence writes
|
||||
// for SQLite's single writer lock. Challenges are keyed by the random nonce,
|
||||
// not by client ID, so two concurrent tabs can each finish their own login.
|
||||
challengeMu sync.Mutex
|
||||
challenges map[string]challengeEntry
|
||||
}
|
||||
|
||||
// New keeps the database parameter for source compatibility with older builds.
|
||||
// Authentication challenge state no longer writes to the database.
|
||||
func New(_ *sql.DB, secret string) *Manager {
|
||||
return &Manager{secret: []byte(secret), challenges: make(map[string]challengeEntry)}
|
||||
}
|
||||
|
||||
func ClientID(j PublicJWK) (string, error) {
|
||||
if j.Kty != "EC" || j.Crv != "P-256" || j.X == "" || j.Y == "" {
|
||||
return "", errors.New("only P-256 EC JWK is supported")
|
||||
}
|
||||
s := j.Kty + "|" + j.Crv + "|" + j.X + "|" + j.Y
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return b64.EncodeToString(h[:]), nil
|
||||
}
|
||||
|
||||
func PublicKey(j PublicJWK) (*ecdsa.PublicKey, error) {
|
||||
xb, err := b64.DecodeString(j.X)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
yb, err := b64.DecodeString(j.Y)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x, y := new(big.Int).SetBytes(xb), new(big.Int).SetBytes(yb)
|
||||
if !elliptic.P256().IsOnCurve(x, y) {
|
||||
return nil, errors.New("invalid P-256 point")
|
||||
}
|
||||
return &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, nil
|
||||
}
|
||||
|
||||
func VerifyRaw(pub *ecdsa.PublicKey, message string, sigB64 string) bool {
|
||||
sig, err := b64.DecodeString(sigB64)
|
||||
if err != nil || len(sig) != 64 {
|
||||
return false
|
||||
}
|
||||
h := sha256.Sum256([]byte(message))
|
||||
r := new(big.Int).SetBytes(sig[:32])
|
||||
s := new(big.Int).SetBytes(sig[32:])
|
||||
return ecdsa.Verify(pub, h[:], r, s)
|
||||
}
|
||||
|
||||
func randomB64(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return b64.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (m *Manager) NewChallenge(ctx context.Context, cid string) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
c, err := randomB64(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
expires := now.Add(2 * time.Minute)
|
||||
|
||||
m.challengeMu.Lock()
|
||||
defer m.challengeMu.Unlock()
|
||||
|
||||
// Opportunistic cleanup keeps the map bounded without a maintenance goroutine.
|
||||
for nonce, entry := range m.challenges {
|
||||
if !entry.ExpiresAt.After(now) {
|
||||
delete(m.challenges, nonce)
|
||||
}
|
||||
}
|
||||
m.challenges[c] = challengeEntry{ClientID: cid, ExpiresAt: expires}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ConsumeChallenge(ctx context.Context, cid, challenge string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
m.challengeMu.Lock()
|
||||
defer m.challengeMu.Unlock()
|
||||
|
||||
entry, ok := m.challenges[challenge]
|
||||
if !ok {
|
||||
return errors.New("challenge expired or unknown")
|
||||
}
|
||||
// A known challenge is one-shot even if a malformed login tries to consume it.
|
||||
delete(m.challenges, challenge)
|
||||
if !entry.ExpiresAt.After(time.Now().UTC()) {
|
||||
return errors.New("challenge expired")
|
||||
}
|
||||
if entry.ClientID != cid {
|
||||
return errors.New("challenge identity mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Issue(cid, role string, ttl time.Duration) (string, Claims, error) {
|
||||
sid, err := randomB64(18)
|
||||
if err != nil {
|
||||
return "", Claims{}, err
|
||||
}
|
||||
c := Claims{ClientID: cid, Role: role, SessionID: sid, Exp: time.Now().Add(ttl).Unix()}
|
||||
hdr := b64.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
|
||||
p, _ := json.Marshal(c)
|
||||
payload := b64.EncodeToString(p)
|
||||
unsigned := hdr + "." + payload
|
||||
mac := hmac.New(sha256.New, m.secret)
|
||||
mac.Write([]byte(unsigned))
|
||||
sig := b64.EncodeToString(mac.Sum(nil))
|
||||
return unsigned + "." + sig, c, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Parse(token string) (Claims, error) {
|
||||
var c Claims
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return c, errors.New("bad token")
|
||||
}
|
||||
unsigned := parts[0] + "." + parts[1]
|
||||
mac := hmac.New(sha256.New, m.secret)
|
||||
mac.Write([]byte(unsigned))
|
||||
want := mac.Sum(nil)
|
||||
got, err := b64.DecodeString(parts[2])
|
||||
if err != nil || !hmac.Equal(want, got) {
|
||||
return c, errors.New("bad token signature")
|
||||
}
|
||||
pb, err := b64.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
if err := json.Unmarshal(pb, &c); err != nil {
|
||||
return c, err
|
||||
}
|
||||
if c.Exp < time.Now().Unix() {
|
||||
return c, errors.New("token expired")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConcurrentChallengesSameIdentityDoNotOverwrite(t *testing.T) {
|
||||
m := New(nil, "test-secret")
|
||||
ctx := context.Background()
|
||||
c1, err := m.NewChallenge(ctx, "client-a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c2, err := m.NewChallenge(ctx, "client-a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c1 == c2 {
|
||||
t.Fatal("expected unique challenges")
|
||||
}
|
||||
if err := m.ConsumeChallenge(ctx, "client-a", c1); err != nil {
|
||||
t.Fatalf("first challenge should remain valid: %v", err)
|
||||
}
|
||||
if err := m.ConsumeChallenge(ctx, "client-a", c2); err != nil {
|
||||
t.Fatalf("second challenge should remain valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChallengeIsOneShotAndIdentityBound(t *testing.T) {
|
||||
m := New(nil, "test-secret")
|
||||
ctx := context.Background()
|
||||
c, err := m.NewChallenge(ctx, "client-a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.ConsumeChallenge(ctx, "client-b", c); err == nil {
|
||||
t.Fatal("challenge must not be consumable by another identity")
|
||||
}
|
||||
if err := m.ConsumeChallenge(ctx, "client-a", c); err == nil {
|
||||
t.Fatal("challenge must be one-shot after a consume attempt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManyConcurrentChallenges(t *testing.T) {
|
||||
m := New(nil, "test-secret")
|
||||
ctx := context.Background()
|
||||
const n = 64
|
||||
type item struct{ cid, challenge string }
|
||||
out := make(chan item, n)
|
||||
errCh := make(chan error, n)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
i := i
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
cid := fmt.Sprintf("client-%d", i)
|
||||
c, err := m.NewChallenge(ctx, cid)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
out <- item{cid: cid, challenge: c}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(out)
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for x := range out {
|
||||
if err := m.ConsumeChallenge(ctx, x.cid, x.challenge); err != nil {
|
||||
t.Fatalf("consume %s: %v", x.cid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user