Merge pull request #1465 from dmcgowan/token-server-oauth

Integration token server supporting oauth
This commit is contained in:
Richard Scothern 2016-06-13 15:01:06 -07:00 committed by GitHub
commit fb106e167a
6 changed files with 283 additions and 30 deletions

View file

@ -0,0 +1,38 @@
package main
import (
"net/http"
"github.com/docker/distribution/registry/api/errcode"
)
var (
errGroup = "tokenserver"
// ErrorBadTokenOption is returned when a token parameter is invalid
ErrorBadTokenOption = errcode.Register(errGroup, errcode.ErrorDescriptor{
Value: "BAD_TOKEN_OPTION",
Message: "bad token option",
Description: `This error may be returned when a request for a
token contains an option which is not valid`,
HTTPStatusCode: http.StatusBadRequest,
})
// ErrorMissingRequiredField is returned when a required form field is missing
ErrorMissingRequiredField = errcode.Register(errGroup, errcode.ErrorDescriptor{
Value: "MISSING_REQUIRED_FIELD",
Message: "missing required field",
Description: `This error may be returned when a request for a
token does not contain a required form field`,
HTTPStatusCode: http.StatusBadRequest,
})
// ErrorUnsupportedValue is returned when a form field has an unsupported value
ErrorUnsupportedValue = errcode.Register(errGroup, errcode.ErrorDescriptor{
Value: "UNSUPPORTED_VALUE",
Message: "unsupported value",
Description: `This error may be returned when a request for a
token contains a form field with an unsupported value`,
HTTPStatusCode: http.StatusBadRequest,
})
)

View file

@ -3,8 +3,11 @@ package main
import ( import (
"encoding/json" "encoding/json"
"flag" "flag"
"math/rand"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time"
"github.com/Sirupsen/logrus" "github.com/Sirupsen/logrus"
"github.com/docker/distribution/context" "github.com/docker/distribution/context"
@ -73,15 +76,20 @@ func main() {
logrus.Fatalf("Error initializing access controller: %v", err) logrus.Fatalf("Error initializing access controller: %v", err)
} }
// TODO: Make configurable
issuer.Expiration = 15 * time.Minute
ctx := context.Background() ctx := context.Background()
ts := &tokenServer{ ts := &tokenServer{
issuer: issuer, issuer: issuer,
accessController: ac, accessController: ac,
refreshCache: map[string]refreshToken{},
} }
router := mux.NewRouter() router := mux.NewRouter()
router.Path("/token/").Methods("GET").Handler(handlerWithContext(ctx, ts.getToken)) router.Path("/token/").Methods("GET").Handler(handlerWithContext(ctx, ts.getToken))
router.Path("/token/").Methods("POST").Handler(handlerWithContext(ctx, ts.postToken))
if cert == "" { if cert == "" {
err = http.ListenAndServe(addr, router) err = http.ListenAndServe(addr, router)
@ -120,9 +128,52 @@ func handleError(ctx context.Context, err error, w http.ResponseWriter) {
context.GetResponseLogger(ctx).Info("application error") context.GetResponseLogger(ctx).Info("application error")
} }
var refreshCharacters = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
const refreshTokenLength = 15
func newRefreshToken() string {
s := make([]rune, refreshTokenLength)
for i := range s {
s[i] = refreshCharacters[rand.Intn(len(refreshCharacters))]
}
return string(s)
}
type refreshToken struct {
subject string
service string
}
type tokenServer struct { type tokenServer struct {
issuer *TokenIssuer issuer *TokenIssuer
accessController auth.AccessController accessController auth.AccessController
refreshCache map[string]refreshToken
}
type tokenResponse struct {
Token string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
}
func filterAccessList(ctx context.Context, scope string, requestedAccessList []auth.Access) []auth.Access {
if !strings.HasSuffix(scope, "/") {
scope = scope + "/"
}
grantedAccessList := make([]auth.Access, 0, len(requestedAccessList))
for _, access := range requestedAccessList {
if access.Type != "repository" {
context.GetLogger(ctx).Debugf("Skipping unsupported resource type: %s", access.Type)
continue
}
if !strings.HasPrefix(access.Name, scope) {
context.GetLogger(ctx).Debugf("Resource scope not allowed: %s", access.Name)
continue
}
grantedAccessList = append(grantedAccessList, access)
}
return grantedAccessList
} }
// getToken handles authenticating the request and authorizing access to the // getToken handles authenticating the request and authorizing access to the
@ -133,6 +184,15 @@ func (ts *tokenServer) getToken(ctx context.Context, w http.ResponseWriter, r *h
params := r.URL.Query() params := r.URL.Query()
service := params.Get("service") service := params.Get("service")
scopeSpecifiers := params["scope"] scopeSpecifiers := params["scope"]
var offline bool
if offlineStr := params.Get("offline_token"); offlineStr != "" {
var err error
offline, err = strconv.ParseBool(offlineStr)
if err != nil {
handleError(ctx, ErrorBadTokenOption.WithDetail(err), w)
return
}
}
requestedAccessList := ResolveScopeSpecifiers(ctx, scopeSpecifiers) requestedAccessList := ResolveScopeSpecifiers(ctx, scopeSpecifiers)
@ -166,20 +226,7 @@ func (ts *tokenServer) getToken(ctx context.Context, w http.ResponseWriter, r *h
ctx = context.WithValue(ctx, "requestedAccess", requestedAccessList) ctx = context.WithValue(ctx, "requestedAccess", requestedAccessList)
ctx = context.WithLogger(ctx, context.GetLogger(ctx, "requestedAccess")) ctx = context.WithLogger(ctx, context.GetLogger(ctx, "requestedAccess"))
scopePrefix := username + "/" grantedAccessList := filterAccessList(ctx, username, requestedAccessList)
grantedAccessList := make([]auth.Access, 0, len(requestedAccessList))
for _, access := range requestedAccessList {
if access.Type != "repository" {
context.GetLogger(ctx).Debugf("Skipping unsupported resource type: %s", access.Type)
continue
}
if !strings.HasPrefix(access.Name, scopePrefix) {
context.GetLogger(ctx).Debugf("Resource scope not allowed: %s", access.Name)
continue
}
grantedAccessList = append(grantedAccessList, access)
}
ctx = context.WithValue(ctx, "grantedAccess", grantedAccessList) ctx = context.WithValue(ctx, "grantedAccess", grantedAccessList)
ctx = context.WithLogger(ctx, context.GetLogger(ctx, "grantedAccess")) ctx = context.WithLogger(ctx, context.GetLogger(ctx, "grantedAccess"))
@ -191,11 +238,151 @@ func (ts *tokenServer) getToken(ctx context.Context, w http.ResponseWriter, r *h
context.GetLogger(ctx).Info("authorized client") context.GetLogger(ctx).Info("authorized client")
// Get response context. response := tokenResponse{
Token: token,
ExpiresIn: int(ts.issuer.Expiration.Seconds()),
}
if offline {
response.RefreshToken = newRefreshToken()
ts.refreshCache[response.RefreshToken] = refreshToken{
subject: username,
service: service,
}
}
ctx, w = context.WithResponseWriter(ctx, w) ctx, w = context.WithResponseWriter(ctx, w)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"token": token}) json.NewEncoder(w).Encode(response)
context.GetResponseLogger(ctx).Info("get token complete") context.GetResponseLogger(ctx).Info("get token complete")
} }
type postTokenResponse struct {
Token string `json:"access_token"`
Scope string `json:"scope,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
}
// postToken handles authenticating the request and authorizing access to the
// requested scopes.
func (ts *tokenServer) postToken(ctx context.Context, w http.ResponseWriter, r *http.Request) {
grantType := r.PostFormValue("grant_type")
if grantType == "" {
handleError(ctx, ErrorMissingRequiredField.WithDetail("missing grant_type value"), w)
return
}
service := r.PostFormValue("service")
if service == "" {
handleError(ctx, ErrorMissingRequiredField.WithDetail("missing service value"), w)
return
}
clientID := r.PostFormValue("client_id")
if clientID == "" {
handleError(ctx, ErrorMissingRequiredField.WithDetail("missing client_id value"), w)
return
}
var offline bool
switch r.PostFormValue("access_type") {
case "", "online":
case "offline":
offline = true
default:
handleError(ctx, ErrorUnsupportedValue.WithDetail("unknown access_type value"), w)
return
}
requestedAccessList := ResolveScopeList(ctx, r.PostFormValue("scope"))
var subject string
var rToken string
switch grantType {
case "refresh_token":
rToken = r.PostFormValue("refresh_token")
if rToken == "" {
handleError(ctx, ErrorUnsupportedValue.WithDetail("missing refresh_token value"), w)
return
}
rt, ok := ts.refreshCache[rToken]
if !ok || rt.service != service {
handleError(ctx, errcode.ErrorCodeUnauthorized.WithDetail("invalid refresh token"), w)
return
}
subject = rt.subject
case "password":
ca, ok := ts.accessController.(auth.CredentialAuthenticator)
if !ok {
handleError(ctx, ErrorUnsupportedValue.WithDetail("password grant type not supported"), w)
return
}
subject = r.PostFormValue("username")
if subject == "" {
handleError(ctx, ErrorUnsupportedValue.WithDetail("missing username value"), w)
return
}
password := r.PostFormValue("password")
if password == "" {
handleError(ctx, ErrorUnsupportedValue.WithDetail("missing password value"), w)
return
}
if err := ca.AuthenticateUser(subject, password); err != nil {
handleError(ctx, errcode.ErrorCodeUnauthorized.WithDetail("invalid credentials"), w)
return
}
default:
handleError(ctx, ErrorUnsupportedValue.WithDetail("unknown grant_type value"), w)
return
}
ctx = context.WithValue(ctx, "acctSubject", subject)
ctx = context.WithLogger(ctx, context.GetLogger(ctx, "acctSubject"))
context.GetLogger(ctx).Info("authenticated client")
ctx = context.WithValue(ctx, "requestedAccess", requestedAccessList)
ctx = context.WithLogger(ctx, context.GetLogger(ctx, "requestedAccess"))
grantedAccessList := filterAccessList(ctx, subject, requestedAccessList)
ctx = context.WithValue(ctx, "grantedAccess", grantedAccessList)
ctx = context.WithLogger(ctx, context.GetLogger(ctx, "grantedAccess"))
token, err := ts.issuer.CreateJWT(subject, service, grantedAccessList)
if err != nil {
handleError(ctx, err, w)
return
}
context.GetLogger(ctx).Info("authorized client")
response := postTokenResponse{
Token: token,
ExpiresIn: int(ts.issuer.Expiration.Seconds()),
IssuedAt: time.Now().UTC().Format(time.RFC3339),
Scope: ToScopeList(grantedAccessList),
}
if offline {
rToken = newRefreshToken()
ts.refreshCache[rToken] = refreshToken{
subject: subject,
service: service,
}
}
if rToken != "" {
response.RefreshToken = rToken
}
ctx, w = context.WithResponseWriter(ctx, w)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
context.GetResponseLogger(ctx).Info("post token complete")
}

View file

@ -55,6 +55,23 @@ func ResolveScopeSpecifiers(ctx context.Context, scopeSpecs []string) []auth.Acc
return requestedAccessList return requestedAccessList
} }
// ResolveScopeList converts a scope list from a token request's
// `scope` parameter into a list of standard access objects.
func ResolveScopeList(ctx context.Context, scopeList string) []auth.Access {
scopes := strings.Split(scopeList, " ")
return ResolveScopeSpecifiers(ctx, scopes)
}
// ToScopeList converts a list of access to a
// scope list string
func ToScopeList(access []auth.Access) string {
var s []string
for _, a := range access {
s = append(s, fmt.Sprintf("%s:%s:%s", a.Type, a.Name, a.Action))
}
return strings.Join(s, ",")
}
// TokenIssuer represents an issuer capable of generating JWT tokens // TokenIssuer represents an issuer capable of generating JWT tokens
type TokenIssuer struct { type TokenIssuer struct {
Issuer string Issuer string

View file

@ -33,6 +33,7 @@
package auth package auth
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
@ -49,6 +50,14 @@ const (
UserNameKey = "auth.user.name" UserNameKey = "auth.user.name"
) )
var (
// ErrInvalidCredential is returned when the auth token does not authenticate correctly.
ErrInvalidCredential = errors.New("invalid authorization credential")
// ErrAuthenticationFailure returned when authentication fails.
ErrAuthenticationFailure = errors.New("authentication failure")
)
// UserInfo carries information about // UserInfo carries information about
// an autenticated/authorized client. // an autenticated/authorized client.
type UserInfo struct { type UserInfo struct {
@ -97,6 +106,11 @@ type AccessController interface {
Authorized(ctx context.Context, access ...Access) (context.Context, error) Authorized(ctx context.Context, access ...Access) (context.Context, error)
} }
// CredentialAuthenticator is an object which is able to authenticate credentials
type CredentialAuthenticator interface {
AuthenticateUser(username, password string) error
}
// WithUser returns a context with the authorized user info. // WithUser returns a context with the authorized user info.
func WithUser(ctx context.Context, user UserInfo) context.Context { func WithUser(ctx context.Context, user UserInfo) context.Context {
return userInfoContext{ return userInfoContext{

View file

@ -6,7 +6,6 @@
package htpasswd package htpasswd
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@ -15,14 +14,6 @@ import (
"github.com/docker/distribution/registry/auth" "github.com/docker/distribution/registry/auth"
) )
var (
// ErrInvalidCredential is returned when the auth token does not authenticate correctly.
ErrInvalidCredential = errors.New("invalid authorization credential")
// ErrAuthenticationFailure returned when authentication failure to be presented to agent.
ErrAuthenticationFailure = errors.New("authentication failure")
)
type accessController struct { type accessController struct {
realm string realm string
htpasswd *htpasswd htpasswd *htpasswd
@ -65,21 +56,25 @@ func (ac *accessController) Authorized(ctx context.Context, accessRecords ...aut
if !ok { if !ok {
return nil, &challenge{ return nil, &challenge{
realm: ac.realm, realm: ac.realm,
err: ErrInvalidCredential, err: auth.ErrInvalidCredential,
} }
} }
if err := ac.htpasswd.authenticateUser(username, password); err != nil { if err := ac.AuthenticateUser(username, password); err != nil {
context.GetLogger(ctx).Errorf("error authenticating user %q: %v", username, err) context.GetLogger(ctx).Errorf("error authenticating user %q: %v", username, err)
return nil, &challenge{ return nil, &challenge{
realm: ac.realm, realm: ac.realm,
err: ErrAuthenticationFailure, err: auth.ErrAuthenticationFailure,
} }
} }
return auth.WithUser(ctx, auth.UserInfo{Name: username}), nil return auth.WithUser(ctx, auth.UserInfo{Name: username}), nil
} }
func (ac *accessController) AuthenticateUser(username, password string) error {
return ac.htpasswd.authenticateUser(username, password)
}
// challenge implements the auth.Challenge interface. // challenge implements the auth.Challenge interface.
type challenge struct { type challenge struct {
realm string realm string

View file

@ -6,6 +6,8 @@ import (
"io" "io"
"strings" "strings"
"github.com/docker/distribution/registry/auth"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@ -33,12 +35,12 @@ func (htpasswd *htpasswd) authenticateUser(username string, password string) err
// timing attack paranoia // timing attack paranoia
bcrypt.CompareHashAndPassword([]byte{}, []byte(password)) bcrypt.CompareHashAndPassword([]byte{}, []byte(password))
return ErrAuthenticationFailure return auth.ErrAuthenticationFailure
} }
err := bcrypt.CompareHashAndPassword([]byte(credentials), []byte(password)) err := bcrypt.CompareHashAndPassword([]byte(credentials), []byte(password))
if err != nil { if err != nil {
return ErrAuthenticationFailure return auth.ErrAuthenticationFailure
} }
return nil return nil