1
0
mirror of https://github.com/regclient/regclient.git synced 2025-04-18 22:44:00 +03:00
Brandon Mitchell eea06e2a5c
Refactoring the type package
I feel like I need to explain, this is all to move the descriptor package.
The platform package could not use the predefined errors in types because of a circular dependency from descriptor.
The most appropriate way to reorg this is to move descriptor out of the type package since it was more complex than a self contained type.
When doing that, type aliases were needed to avoid breaking changes to existing users.
Those aliases themselves caused circular dependency loops because of the media types and errors, so those were also pulled out to separate packages.
All of the old values were aliased and deprecated, and to fix the linter, those deprecations were fixed by updating the imports... everywhere.

Signed-off-by: Brandon Mitchell <git@bmitch.net>
2024-03-04 15:43:18 -05:00

853 lines
21 KiB
Go

// Package auth is used for HTTP authentication
package auth
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/sirupsen/logrus"
"github.com/regclient/regclient/types/errs"
)
type charLU byte
var charLUs [256]charLU
var defaultClientID = "regclient"
// minTokenLife tokens are required to last at least 60 seconds to support older docker clients
var minTokenLife = 60
// tokenBuffer is used to renew a token before it expires to account for time to process requests on the server
var tokenBuffer = time.Second * 5
const (
isSpace charLU = 1 << iota
isToken
)
func init() {
for c := 0; c < 256; c++ {
charLUs[c] = 0
if strings.ContainsRune(" \t\r\n", rune(c)) {
charLUs[c] |= isSpace
}
if (rune('a') <= rune(c) && rune(c) <= rune('z')) || (rune('A') <= rune(c) && rune(c) <= rune('Z') || (rune('0') <= rune(c) && rune(c) <= rune('9')) || strings.ContainsRune("-._~+/", rune(c))) {
charLUs[c] |= isToken
}
}
}
// CredsFn is passed to lookup credentials for a given hostname, response is a username and password or empty strings
type CredsFn func(string) Cred
// Cred is returned by the CredsFn
type Cred struct {
User, Password, Token string
}
// Auth manages authorization requests/responses for http requests
type Auth interface {
AddScope(host, scope string) error
HandleResponse(*http.Response) error
UpdateRequest(*http.Request) error
}
// Challenge is the extracted contents of the WWW-Authenticate header
type Challenge struct {
authType string
params map[string]string
}
// Handler handles a challenge for a host to return an auth header
type Handler interface {
AddScope(scope string) error
ProcessChallenge(Challenge) error
GenerateAuth() (string, error)
}
// HandlerBuild is used to make a new handler for a specific authType and URL
type HandlerBuild func(client *http.Client, clientID, host string, credFn CredsFn, log *logrus.Logger) Handler
// Opts configures options for NewAuth
type Opts func(*auth)
type auth struct {
httpClient *http.Client
clientID string
credsFn CredsFn
hbs map[string]HandlerBuild // handler builders based on authType
hs map[string]map[string]Handler // handlers based on url and authType
authTypes []string
log *logrus.Logger
mu sync.Mutex
}
// NewAuth creates a new Auth
func NewAuth(opts ...Opts) Auth {
a := &auth{
httpClient: &http.Client{},
clientID: defaultClientID,
credsFn: DefaultCredsFn,
hbs: map[string]HandlerBuild{},
hs: map[string]map[string]Handler{},
authTypes: []string{},
}
a.log = &logrus.Logger{
Out: os.Stderr,
Formatter: new(logrus.TextFormatter),
Hooks: make(logrus.LevelHooks),
Level: logrus.WarnLevel,
}
for _, opt := range opts {
opt(a)
}
if len(a.authTypes) == 0 {
a.addDefaultHandlers()
}
return a
}
// WithCreds provides a user/pass lookup for a url
func WithCreds(f CredsFn) Opts {
return func(a *auth) {
if f != nil {
a.credsFn = f
}
}
}
// WithHTTPClient uses a specific http client with requests
func WithHTTPClient(h *http.Client) Opts {
return func(a *auth) {
if h != nil {
a.httpClient = h
}
}
}
// WithClientID uses a client ID with request headers
func WithClientID(clientID string) Opts {
return func(a *auth) {
a.clientID = clientID
}
}
// WithHandler includes a handler for a specific auth type
func WithHandler(authType string, hb HandlerBuild) Opts {
return func(a *auth) {
lcat := strings.ToLower(authType)
a.hbs[lcat] = hb
a.authTypes = append(a.authTypes, lcat)
}
}
// WithDefaultHandlers includes a Basic and Bearer handler, this is automatically added with "WithHandler" is not called
func WithDefaultHandlers() Opts {
return func(a *auth) {
a.addDefaultHandlers()
}
}
// WithLog injects a logrus Logger
func WithLog(log *logrus.Logger) Opts {
return func(a *auth) {
a.log = log
}
}
// AddScope extends an existing auth with additional scopes.
// This is used to pre-populate scopes with the Docker convention rather than
// depend on the registry to respond with the correct http status and headers.
func (a *auth) AddScope(host, scope string) error {
a.mu.Lock()
defer a.mu.Unlock()
success := false
if a.hs[host] == nil {
return ErrNoNewChallenge
}
for _, at := range a.authTypes {
if a.hs[host][at] != nil {
err := a.hs[host][at].AddScope(scope)
if err == nil {
success = true
} else if err != ErrNoNewChallenge {
return err
}
}
}
if !success {
return ErrNoNewChallenge
}
a.log.WithFields(logrus.Fields{
"host": host,
"scope": scope,
}).Debug("Auth scope added")
return nil
}
// HandleResponse parses the 401 response, extracting the WWW-Authenticate
// header and verifying the requirement is different from what was included in
// the last request
func (a *auth) HandleResponse(resp *http.Response) error {
a.mu.Lock()
defer a.mu.Unlock()
// verify response is an access denied
if resp.StatusCode != http.StatusUnauthorized {
return ErrUnsupported
}
// extract host and auth header
host := resp.Request.URL.Host
cl, err := ParseAuthHeaders(resp.Header.Values("WWW-Authenticate"))
if err != nil {
return err
}
a.log.WithFields(logrus.Fields{
"challenge": cl,
}).Debug("Auth request parsed")
if len(cl) < 1 {
return ErrEmptyChallenge
}
goodChallenge := false
// loop over the received challenge(s)
for _, c := range cl {
if _, ok := a.hbs[c.authType]; !ok {
a.log.WithFields(logrus.Fields{
"authtype": c.authType,
}).Warn("Unsupported auth type")
continue
}
// setup a handler for the host and auth type
if _, ok := a.hs[host]; !ok {
a.hs[host] = map[string]Handler{}
}
if _, ok := a.hs[host][c.authType]; !ok {
h := a.hbs[c.authType](a.httpClient, a.clientID, host, a.credsFn, a.log)
if h == nil {
continue
}
a.hs[host][c.authType] = h
}
// process the challenge with that handler
err := a.hs[host][c.authType].ProcessChallenge(c)
if err == nil {
goodChallenge = true
} else if err == ErrNoNewChallenge {
// handle race condition when another request updates the challenge
// detect that by seeing the current auth header is different
prevAH := resp.Request.Header.Get("Authorization")
ah, err := a.hs[host][c.authType].GenerateAuth()
if err == nil && prevAH != ah {
goodChallenge = true
}
} else {
return err
}
}
if !goodChallenge {
return ErrUnauthorized
}
return nil
}
// UpdateRequest adds Authorization headers to a request
func (a *auth) UpdateRequest(req *http.Request) error {
a.mu.Lock()
defer a.mu.Unlock()
host := req.URL.Host
if a.hs[host] == nil {
return nil
}
var err error
var ah string
for _, at := range a.authTypes {
if a.hs[host][at] != nil {
ah, err = a.hs[host][at].GenerateAuth()
if err != nil {
a.log.WithFields(logrus.Fields{
"err": err,
"host": host,
"authtype": at,
}).Debug("Failed to generate auth")
continue
}
req.Header.Set("Authorization", ah)
break
}
}
if err != nil {
return err
}
return nil
}
func (a *auth) addDefaultHandlers() {
if _, ok := a.hbs["basic"]; !ok {
a.hbs["basic"] = NewBasicHandler
a.authTypes = append(a.authTypes, "basic")
}
if _, ok := a.hbs["bearer"]; !ok {
a.hbs["bearer"] = NewBearerHandler
a.authTypes = append(a.authTypes, "bearer")
}
// jwt is considered experimental, used for some Hub specific API's
if _, ok := a.hbs["jwt"]; !ok {
a.hbs["jwt"] = NewJWTHandler
a.authTypes = append(a.authTypes, "jwt")
}
}
// DefaultCredsFn is used to return no credentials when auth is not configured with a CredsFn
// This avoids the need to check for nil pointers
func DefaultCredsFn(h string) Cred {
return Cred{}
}
// ParseAuthHeaders extracts the scheme and realm from WWW-Authenticate headers
func ParseAuthHeaders(ahl []string) ([]Challenge, error) {
var cl []Challenge
for _, ah := range ahl {
c, err := ParseAuthHeader(ah)
if err != nil {
return nil, fmt.Errorf("failed to parse challenge header: %s, %w", ah, err)
}
cl = append(cl, c...)
}
return cl, nil
}
// ParseAuthHeader parses a single header line for WWW-Authenticate
// Example values:
// Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:samalba/my-app:pull,push"
// Basic realm="GitHub Package Registry"
func ParseAuthHeader(ah string) ([]Challenge, error) {
var cl []Challenge
var c *Challenge
var eb, atb, kb, vb []byte // eb is element bytes, atb auth type, kb key, vb value
state := "string"
for _, b := range []byte(ah) {
switch state {
case "string":
if len(eb) == 0 {
// beginning of string
if b == '"' { // TODO: Invalid?
state = "quoted"
} else if charLUs[b]&isToken != 0 {
// read any token
eb = append(eb, b)
} else if charLUs[b]&isSpace != 0 {
// ignore leading whitespace
} else {
// unknown leading char
return nil, ErrParseFailure
}
} else {
if charLUs[b]&isToken != 0 {
// read any token
eb = append(eb, b)
} else if b == '=' && len(atb) > 0 {
// equals when authtype is defined makes this a key
kb = eb
eb = []byte{}
state = "value"
} else if charLUs[b]&isSpace != 0 {
// space ends the element
atb = eb
eb = []byte{}
c = &Challenge{authType: strings.ToLower(string(atb)), params: map[string]string{}}
cl = append(cl, *c)
} else {
// unknown char
return nil, ErrParseFailure
}
}
case "value":
if charLUs[b]&isToken != 0 {
// read any token
vb = append(vb, b)
} else if b == '"' && len(vb) == 0 {
// quoted value
state = "quoted"
} else if charLUs[b]&isSpace != 0 || b == ',' {
// space or comma ends the value
c.params[strings.ToLower(string(kb))] = string(vb)
kb = []byte{}
vb = []byte{}
if b == ',' {
state = "string"
} else {
state = "endvalue"
}
} else {
// unknown char
return nil, ErrParseFailure
}
case "quoted":
if b == '"' {
// end quoted string
c.params[strings.ToLower(string(kb))] = string(vb)
kb = []byte{}
vb = []byte{}
state = "endvalue"
} else if b == '\\' {
state = "escape"
} else {
// all other bytes in a quoted string are taken as-is
vb = append(vb, b)
}
case "endvalue":
if charLUs[b]&isSpace != 0 {
// ignore leading whitespace
} else if b == ',' {
// expect a comma separator, return to start of a string
state = "string"
} else {
// unknown char
return nil, ErrParseFailure
}
case "escape":
vb = append(vb, b)
state = "quoted"
default:
return nil, ErrParseFailure
}
}
// process any content left at end of string, and handle any unfinished sections
switch state {
case "string":
if len(eb) != 0 {
atb = eb
c = &Challenge{authType: strings.ToLower(string(atb)), params: map[string]string{}}
cl = append(cl, *c)
}
case "value":
if len(vb) != 0 {
c.params[strings.ToLower(string(kb))] = string(vb)
}
case "quoted", "escape":
return nil, ErrParseFailure
}
return cl, nil
}
// BasicHandler supports Basic auth type requests
type BasicHandler struct {
realm string
host string
credsFn CredsFn
}
// NewBasicHandler creates a new BasicHandler
func NewBasicHandler(client *http.Client, clientID, host string, credsFn CredsFn, log *logrus.Logger) Handler {
return &BasicHandler{
realm: "",
host: host,
credsFn: credsFn,
}
}
// AddScope is not valid for BasicHandler
func (b *BasicHandler) AddScope(scope string) error {
return ErrNoNewChallenge
}
// ProcessChallenge for BasicHandler is a noop
func (b *BasicHandler) ProcessChallenge(c Challenge) error {
if _, ok := c.params["realm"]; !ok {
return ErrInvalidChallenge
}
if b.realm != c.params["realm"] {
b.realm = c.params["realm"]
return nil
}
return ErrNoNewChallenge
}
// GenerateAuth for BasicHandler generates base64 encoded user/pass for a host
func (b *BasicHandler) GenerateAuth() (string, error) {
cred := b.credsFn(b.host)
if cred.User == "" || cred.Password == "" {
return "", fmt.Errorf("no credentials available: %w", errs.ErrHTTPUnauthorized)
}
auth := base64.StdEncoding.EncodeToString([]byte(cred.User + ":" + cred.Password))
return fmt.Sprintf("Basic %s", auth), nil
}
// BearerHandler supports Bearer auth type requests
type BearerHandler struct {
client *http.Client
clientID string
realm, service string
host string
credsFn CredsFn
scopes []string
token BearerToken
log *logrus.Logger
}
// BearerToken is the json response to the Bearer request
type BearerToken struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
IssuedAt time.Time `json:"issued_at"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
}
// NewBearerHandler creates a new BearerHandler
func NewBearerHandler(client *http.Client, clientID, host string, credsFn CredsFn, log *logrus.Logger) Handler {
return &BearerHandler{
client: client,
clientID: clientID,
host: host,
credsFn: credsFn,
realm: "",
service: "",
scopes: []string{},
log: log,
}
}
// AddScope appends a new scope if it doesn't already exist
func (b *BearerHandler) AddScope(scope string) error {
if b.scopeExists(scope) {
if b.token.Token == "" || !b.isExpired() {
return ErrNoNewChallenge
}
return nil
}
return b.addScope(scope)
}
func (b *BearerHandler) addScope(scope string) error {
replaced := false
for i, cur := range b.scopes {
// extend an existing scope with more actions
if strings.HasPrefix(scope, cur+",") {
b.scopes[i] = scope
replaced = true
break
}
}
if !replaced {
b.scopes = append(b.scopes, scope)
}
// delete any scope specific or invalid tokens
b.token.Token = ""
b.token.RefreshToken = ""
return nil
}
// ProcessChallenge handles WWW-Authenticate header for bearer tokens
// Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:samalba/my-app:pull,push"
func (b *BearerHandler) ProcessChallenge(c Challenge) error {
if _, ok := c.params["realm"]; !ok {
return ErrInvalidChallenge
}
if _, ok := c.params["service"]; !ok {
c.params["service"] = ""
}
if _, ok := c.params["scope"]; !ok {
c.params["scope"] = ""
}
existingScope := b.scopeExists(c.params["scope"])
if b.realm == c.params["realm"] && b.service == c.params["service"] && existingScope && (b.token.Token == "" || !b.isExpired()) {
return ErrNoNewChallenge
}
if b.realm == "" {
b.realm = c.params["realm"]
} else if b.realm != c.params["realm"] {
return ErrInvalidChallenge
}
if b.service == "" {
b.service = c.params["service"]
} else if b.service != c.params["service"] {
return ErrInvalidChallenge
}
if !existingScope {
return b.addScope(c.params["scope"])
}
return nil
}
// GenerateAuth for BasicHandler generates base64 encoded user/pass for a host
func (b *BearerHandler) GenerateAuth() (string, error) {
// if unexpired token already exists, return it
if b.token.Token != "" && !b.isExpired() {
return fmt.Sprintf("Bearer %s", b.token.Token), nil
}
// attempt to post with oauth form, this also uses refresh tokens
if err := b.tryPost(); err == nil {
return fmt.Sprintf("Bearer %s", b.token.Token), nil
} else if err != ErrUnauthorized {
return "", fmt.Errorf("failed to request auth token (post): %w%.0w", err, errs.ErrHTTPUnauthorized)
}
// attempt a get (with basic auth if user/pass available)
if err := b.tryGet(); err == nil {
return fmt.Sprintf("Bearer %s", b.token.Token), nil
} else if err != ErrUnauthorized {
return "", fmt.Errorf("failed to request auth token (get): %w%.0w", err, errs.ErrHTTPUnauthorized)
}
return "", ErrUnauthorized
}
// isExpired returns true when token issue date is either 0, token has expired,
// or will expire within buffer time
func (b *BearerHandler) isExpired() bool {
if b.token.IssuedAt.IsZero() {
return true
}
expireSec := b.token.IssuedAt.Add(time.Duration(b.token.ExpiresIn) * time.Second)
expireSec = expireSec.Add(tokenBuffer * -1)
return time.Now().After(expireSec)
}
// tryGet requests a new token with a GET request
func (b *BearerHandler) tryGet() error {
cred := b.credsFn(b.host)
req, err := http.NewRequest("GET", b.realm, nil)
if err != nil {
return err
}
reqParams := req.URL.Query()
reqParams.Add("client_id", b.clientID)
reqParams.Add("offline_token", "true")
if b.service != "" {
reqParams.Add("service", b.service)
}
for _, s := range b.scopes {
reqParams.Add("scope", s)
}
if cred.User != "" && cred.Password != "" {
reqParams.Add("account", cred.User)
req.SetBasicAuth(cred.User, cred.Password)
}
req.Header.Add("User-Agent", b.clientID)
req.URL.RawQuery = reqParams.Encode()
resp, err := b.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return b.validateResponse(resp)
}
// tryPost requests a new token via a POST request
func (b *BearerHandler) tryPost() error {
cred := b.credsFn(b.host)
form := url.Values{}
if len(b.scopes) > 0 {
form.Set("scope", strings.Join(b.scopes, " "))
}
if b.service != "" {
form.Set("service", b.service)
}
form.Set("client_id", b.clientID)
if b.token.RefreshToken != "" {
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", b.token.RefreshToken)
} else if cred.Token != "" {
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", cred.Token)
} else if cred.User != "" && cred.Password != "" {
form.Set("grant_type", "password")
form.Set("username", cred.User)
form.Set("password", cred.Password)
}
req, err := http.NewRequest("POST", b.realm, strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
req.Header.Add("User-Agent", b.clientID)
resp, err := b.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return b.validateResponse(resp)
}
// scopeExists check if the scope already exists within the list of scopes
func (b *BearerHandler) scopeExists(search string) bool {
if search == "" {
return true
}
for _, scope := range b.scopes {
// allow scopes with additional actions, search for pull should match pull,push
if scope == search || strings.HasPrefix(scope, search+",") {
return true
}
}
return false
}
// validateResponse extracts the returned token
func (b *BearerHandler) validateResponse(resp *http.Response) error {
if resp.StatusCode != 200 {
return ErrUnauthorized
}
// decode response and if successful, update token
decoder := json.NewDecoder(resp.Body)
decoded := BearerToken{}
if err := decoder.Decode(&decoded); err != nil {
return err
}
b.token = decoded
if b.token.ExpiresIn < minTokenLife {
b.token.ExpiresIn = minTokenLife
}
// If token is already expired, it was sent with a zero value or
// there may be a clock skew between the client and auth server.
// Also handle cases of remote time in the future.
// But if remote time is slightly in the past, leave as is so token
// expires here before the server.
if b.isExpired() || b.token.IssuedAt.After(time.Now()) {
b.token.IssuedAt = time.Now().UTC()
}
// AccessToken and Token should be the same and we use Token elsewhere
if b.token.AccessToken != "" {
b.token.Token = b.token.AccessToken
}
return nil
}
// JWTHubHandler supports JWT auth type requests
type JWTHubHandler struct {
client *http.Client
clientID string
realm string
host string
credsFn CredsFn
jwt string
}
type jwtHubPost struct {
User string `json:"username"`
Pass string `json:"password"`
}
type jwtHubResp struct {
Detail string `json:"detail"`
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
}
// NewJWTHandler creates a new JWTHandler
func NewJWTHandler(client *http.Client, clientID, host string, credsFn CredsFn, log *logrus.Logger) Handler {
// JWT handler is only tested against Hub, and the API is Hub specific
if host == "hub.docker.com" {
return &JWTHubHandler{
client: client,
clientID: clientID,
host: host,
credsFn: credsFn,
realm: "https://hub.docker.com/v2/users/login",
}
}
return nil
}
// AddScope is not valid for JWTHubHandler
func (j *JWTHubHandler) AddScope(scope string) error {
return ErrNoNewChallenge
}
// ProcessChallenge handles WWW-Authenticate header for JWT auth on Docker Hub
func (j *JWTHubHandler) ProcessChallenge(c Challenge) error {
cred := j.credsFn(j.host)
// use token if provided
if cred.Token != "" {
j.jwt = cred.Token
return nil
}
// send a login request to hub
bodyBytes, err := json.Marshal(jwtHubPost{
User: cred.User,
Pass: cred.Password,
})
if err != nil {
return err
}
req, err := http.NewRequest("POST", j.realm, bytes.NewReader(bodyBytes))
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
req.Header.Add("User-Agent", j.clientID)
resp, err := j.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 || resp.StatusCode >= 300 {
return ErrUnauthorized
}
var bodyParsed jwtHubResp
err = json.Unmarshal(body, &bodyParsed)
if err != nil {
return err
}
j.jwt = bodyParsed.Token
return nil
}
// GenerateAuth for JWTHubHandler adds JWT header
func (j *JWTHubHandler) GenerateAuth() (string, error) {
if len(j.jwt) > 0 {
return fmt.Sprintf("JWT %s", j.jwt), nil
}
return "", ErrUnauthorized
}