1
0
mirror of https://github.com/quay/quay.git synced 2026-10-23 09:49:06 +03:00
Files
quay/internal/auth/basic_test.go
jbpratt 019a463ea8 NO-ISSUE: feat(auth): add robot token authentication (#6392)
Add a verifier abstraction behind BasicAuthenticator so user password
auth and static robot token auth share credential verification without
coupling robot-specific behavior to HTTP Basic parsing.

Implement Python-compatible legacy robot token handling for
RobotAccountToken rows, including DATABASE_SECRET_KEY conversion,
encrypted-field AES-CCM decryption, robot allowlist enforcement, ASCII
credential validation, robot and owner enabled checks, and last_accessed
updates.

Wire the database verifier into API and distribution authentication,
then enforce pull, push, delete, and create-on-push authorization for
user and robot principals, including public pulls, disabled namespace
checks, repository state/kind checks, and superuser create behavior.

Extend the Go migration E2E job to create a robot through old OMR's
session and CSRF API flow, verify that token can push before migration,
and verify the same migrated token can pull and push against the Go
registry. Generate a migrated runtime config that preserves OMR secret
keys while pointing Go at the migrated database and storage paths.
2026-07-10 12:59:38 -05:00

257 lines
7.2 KiB
Go

package auth
import (
"bytes"
"context"
"database/sql"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
func setupAuthTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
db.SetMaxOpenConns(1)
_, err = db.ExecContext(t.Context(), `CREATE TABLE "user" (
id INTEGER PRIMARY KEY,
uuid VARCHAR(36),
username VARCHAR(255) NOT NULL,
password_hash VARCHAR(255),
email VARCHAR(255) NOT NULL,
verified INTEGER NOT NULL DEFAULT 0,
organization INTEGER NOT NULL DEFAULT 0,
robot INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1
)`)
if err != nil {
t.Fatalf("create user table: %v", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte("correct-password"), bcrypt.MinCost)
if err != nil {
t.Fatalf("bcrypt: %v", err)
}
_, err = db.ExecContext(t.Context(), `INSERT INTO "user" (uuid, username, password_hash, email, verified, organization, robot, enabled)
VALUES ('u1', 'admin', ?, 'admin@test.com', 1, 0, 0, 1)`, string(hash))
if err != nil {
t.Fatalf("insert user: %v", err)
}
return db
}
type recordingVerifier struct {
called bool
ctx context.Context
credentials Credentials
result Result
}
func (v *recordingVerifier) Verify(ctx context.Context, credentials Credentials) Result {
v.called = true
v.ctx = ctx
v.credentials = credentials
return v.result
}
func TestBasicAuthenticatorDelegatesParsedCredentialsToVerifier(t *testing.T) {
verifierResult := Result{
Principal: Principal{ID: 17, Username: "verified-user", Kind: PrincipalUser},
Username: "verified-user",
Presented: true,
Authenticated: true,
}
verifier := &recordingVerifier{result: verifierResult}
authenticator := NewBasicAuthenticator(verifier)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
req.SetBasicAuth("admin", "correct-password")
result := authenticator.Authenticate(req)
if !verifier.called {
t.Fatal("verifier was not called")
}
if verifier.ctx != req.Context() {
t.Fatal("verifier context does not match request context")
}
if verifier.credentials.Username != "admin" {
t.Fatalf("username = %q, want admin", verifier.credentials.Username)
}
if verifier.credentials.Secret != "correct-password" {
t.Fatalf("secret = %q, want correct-password", verifier.credentials.Secret)
}
if result != verifierResult {
t.Fatalf("result = %#v, want %#v", result, verifierResult)
}
}
func TestBasicAuthenticatorMissingCredentialsDoesNotCallVerifier(t *testing.T) {
verifier := &recordingVerifier{result: Result{Authenticated: true}}
authenticator := NewBasicAuthenticator(verifier)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
result := authenticator.Authenticate(req)
if verifier.called {
t.Fatal("verifier was called")
}
if result.Presented {
t.Fatal("presented = true, want false")
}
if result.Authenticated {
t.Fatal("authenticated = true, want false")
}
}
func TestBasicAuthenticatorMalformedBasicHeaderDoesNotCallVerifier(t *testing.T) {
verifier := &recordingVerifier{result: Result{Authenticated: true}}
authenticator := NewBasicAuthenticator(verifier)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
req.Header.Set("Authorization", "Basic not-base64")
result := authenticator.Authenticate(req)
if verifier.called {
t.Fatal("verifier was called")
}
if !result.Presented {
t.Fatal("presented = false, want true")
}
if result.Authenticated {
t.Fatal("authenticated = true, want false")
}
if result.Username != "" {
t.Fatalf("username = %q, want empty", result.Username)
}
}
func TestBasicAuthenticatorNilVerifierFailsAfterParsingCredentials(t *testing.T) {
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
req.SetBasicAuth("admin", "correct-password")
t.Run("nil authenticator", func(t *testing.T) {
var authenticator *BasicAuthenticator
result := authenticator.Authenticate(req)
if !result.Presented {
t.Fatal("presented = false, want true")
}
if result.Authenticated {
t.Fatal("authenticated = true, want false")
}
if result.Username != "admin" {
t.Fatalf("username = %q, want admin", result.Username)
}
})
t.Run("nil verifier", func(t *testing.T) {
authenticator := NewBasicAuthenticator(nil)
result := authenticator.Authenticate(req)
if !result.Presented {
t.Fatal("presented = false, want true")
}
if result.Authenticated {
t.Fatal("authenticated = true, want false")
}
if result.Username != "admin" {
t.Fatalf("username = %q, want admin", result.Username)
}
})
}
func TestUserPasswordVerifierLogsLookupErrorsOnly(t *testing.T) {
var logs bytes.Buffer
previousLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
defer slog.SetDefault(previousLogger)
db := setupAuthTestDB(t)
t.Cleanup(func() { _ = db.Close() })
verifier := NewUserPasswordVerifier(db)
verifier.Verify(t.Context(), Credentials{Username: "admin", Secret: "wrong-password"})
if strings.Contains(logs.String(), "err=") {
t.Fatalf("invalid credentials log included lookup error: %s", logs.String())
}
logs.Reset()
brokenDB, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open broken db: %v", err)
}
t.Cleanup(func() { _ = brokenDB.Close() })
brokenDB.SetMaxOpenConns(1)
NewUserPasswordVerifier(brokenDB).Verify(t.Context(), Credentials{Username: "admin", Secret: "wrong-password"})
if !strings.Contains(logs.String(), "err=") {
t.Fatalf("lookup failure log missing error: %s", logs.String())
}
}
func TestUserPasswordVerifierResult(t *testing.T) {
db := setupAuthTestDB(t)
defer db.Close()
verifier := NewUserPasswordVerifier(db)
t.Run("valid credentials", func(t *testing.T) {
result := verifier.Verify(t.Context(), Credentials{Username: "admin", Secret: "correct-password"})
if !result.Presented {
t.Fatal("presented = false, want true")
}
if !result.Authenticated {
t.Fatal("authenticated = false, want true")
}
if result.Username != "admin" {
t.Fatalf("username = %q, want admin", result.Username)
}
if result.Principal.Username != "admin" {
t.Fatalf("principal username = %q, want admin", result.Principal.Username)
}
if result.Principal.Kind != PrincipalUser {
t.Fatalf("principal kind = %q, want user", result.Principal.Kind)
}
})
t.Run("invalid credentials", func(t *testing.T) {
result := verifier.Verify(t.Context(), Credentials{Username: "admin", Secret: "wrong-password"})
if !result.Presented {
t.Fatal("presented = false, want true")
}
if result.Authenticated {
t.Fatal("authenticated = true, want false")
}
if result.Username != "admin" {
t.Fatalf("username = %q, want admin", result.Username)
}
})
t.Run("unknown user", func(t *testing.T) {
result := verifier.Verify(t.Context(), Credentials{Username: "unknown", Secret: "wrong-password"})
if !result.Presented {
t.Fatal("presented = false, want true")
}
if result.Authenticated {
t.Fatal("authenticated = true, want false")
}
if result.Username != "unknown" {
t.Fatalf("username = %q, want unknown", result.Username)
}
})
}