lib/cli/cli.go (434 lines of code) (raw):
// Copyright 2020 Google LLC.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package cli adds support for command line interfaces or micro-services
// to establish an access and/or refresh token via user participation.
package cli
import (
"encoding/base64"
"encoding/hex"
"fmt"
"html/template"
"io/ioutil"
"math/rand"
"net/http"
"net/mail"
"net/url"
"path"
"regexp"
"strconv"
"strings"
"time"
"golang.org/x/crypto/sha3" /* copybara-comment */
"github.com/gorilla/mux" /* copybara-comment */
"google.golang.org/grpc/codes" /* copybara-comment */
"google.golang.org/grpc/status" /* copybara-comment */
"github.com/golang/protobuf/proto" /* copybara-comment */
"github.com/golang/protobuf/ptypes" /* copybara-comment */
"github.com/pborman/uuid" /* copybara-comment */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/auth" /* copybara-comment: auth */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/ga4gh" /* copybara-comment: ga4gh */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/handlerfactory" /* copybara-comment: handlerfactory */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/httputils" /* copybara-comment: httputils */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/kms" /* copybara-comment: kms */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/storage" /* copybara-comment: storage */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/translator" /* copybara-comment: translator */
cpb "github.com/GoogleCloudPlatform/healthcare-federated-access-services/proto/common/v1" /* copybara-comment: go_proto */
)
const (
cliPageFile = "pages/cli.html"
staticPath = "/static"
)
var (
ttl = 5 * time.Minute
autoGenerate = "auto"
autoOrUUIDRE = regexp.MustCompile(`^(auto|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$`)
)
// RegisterFactory creates handlers for shell login requests.
func RegisterFactory(store storage.Store, path string, crypt kms.Encryption, cliAuthURL, issuerURL, authURL, tokenURL, accept string, httpClient *http.Client) *handlerfactory.Options {
return &handlerfactory.Options{
TypeName: "registerLogin",
PathPrefix: path,
HasNamedIdentifiers: true,
NameChecker: map[string]*regexp.Regexp{
"name": autoOrUUIDRE,
},
Service: func() handlerfactory.Service {
return NewRegisterHandler(store, crypt, cliAuthURL, issuerURL, authURL, tokenURL, accept, httpClient)
},
}
}
// RegisterHandler handles shell login requests.
type RegisterHandler struct {
store storage.Store
crypt kms.Encryption
cliAuthURL string
issuerURL string
authURL string
tokenURL string
accept string
item *cpb.CliState
save *cpb.CliState
client *http.Client
tx storage.Tx
}
// NewRegisterHandler handles one shell login request.
func NewRegisterHandler(store storage.Store, crypt kms.Encryption, cliAuthURL, issuerURL, authURL, tokenURL, accept string, httpClient *http.Client) *RegisterHandler {
// Better distribution for algorithms using rand by using a different seed.
rand.Seed(time.Now().UnixNano() + int64(rand.Int31()))
return &RegisterHandler{
store: store,
crypt: crypt,
cliAuthURL: cliAuthURL,
issuerURL: issuerURL,
authURL: authURL,
tokenURL: tokenURL,
accept: accept,
item: &cpb.CliState{},
client: httpClient,
}
}
// Setup sets up the handler.
func (h *RegisterHandler) Setup(r *http.Request, tx storage.Tx) (int, error) {
h.item.Reset()
h.tx = tx
return http.StatusOK, nil
}
// LookupItem looks up the item in the storage layer.
func (h *RegisterHandler) LookupItem(r *http.Request, name string, vars map[string]string) bool {
if name == autoGenerate {
return false
}
if err := h.store.ReadTx(storage.CliAuthDatatype, getRealm(r), storage.DefaultUser, name, storage.LatestRev, h.item, h.tx); err != nil {
return false
}
return true
}
// NormalizeInput sets up basic structure of request input objects if absent.
func (h *RegisterHandler) NormalizeInput(r *http.Request, name string, vars map[string]string) error {
return nil
}
// Get is a GET request.
func (h *RegisterHandler) Get(r *http.Request, name string) (proto.Message, error) {
a, err := auth.FromContext(r.Context())
if err != nil {
return nil, status.Errorf(codes.Internal, "cannot obtain request context: %v", err)
}
if h.item.State != storage.StateActive {
return nil, status.Errorf(codes.FailedPrecondition, "login %q has already been granted to a user on a previous call", name)
}
exp, err := ptypes.Timestamp(h.item.ExpiresAt)
if err != nil {
exp = time.Unix(0, 0)
}
if time.Now().Sub(exp) > 0 {
return nil, status.Errorf(codes.DeadlineExceeded, "login %q expired", name)
}
if a.ClientID != h.item.ClientId {
return nil, status.Errorf(codes.Unauthenticated, "login %q unauthorized client", name)
}
secret := httputils.QueryParam(r, "login_secret")
if secret == "" {
return nil, status.Errorf(codes.InvalidArgument, "missing login_secret")
}
if len(h.item.EncryptedSecret) == 0 {
return nil, status.Errorf(codes.Internal, "missing internal secret")
}
decrypted, err := h.crypt.Decrypt(r.Context(), h.item.EncryptedSecret, "")
if err != nil {
return nil, status.Errorf(codes.Internal, "decrypt secret failed: %v", err)
}
if secret != string(decrypted) {
if err := h.store.DeleteTx(storage.CliAuthDatatype, getRealm(r), storage.DefaultUser, name, storage.LatestRev, h.tx); err != nil {
return nil, status.Errorf(codes.Internal, "failed to remove registration")
}
return nil, status.Errorf(codes.Unauthenticated, "unauthorized: missing or invalid login_secret")
}
if len(h.item.EncryptedCode) > 0 {
if err = h.exchangeCode(r, name, a); err != nil {
return nil, err
}
}
h.item.EncryptedSecret = nil
h.item.State = ""
return h.item, nil
}
func (h *RegisterHandler) exchangeCode(r *http.Request, name string, a *auth.Context) error {
// Can only use code once. Exchange code now and mark CliState as DELETED in storage.
// Future calls to GET can give more meaningful error messages to the end user.
if err := h.tx.MakeUpdate(); err != nil {
return status.Errorf(codes.Internal, "storage transaction prepare update failed: %v", err)
}
encrypted := h.item.EncryptedCode
h.item.EncryptedCode = nil
h.item.AuthUrl = ""
h.item.State = storage.StateDeleted
if err := h.store.WriteTx(storage.CliAuthDatatype, getRealm(r), storage.DefaultUser, name, storage.LatestRev, h.item, nil, h.tx); err != nil {
return status.Errorf(codes.Internal, "failed to remove registration")
}
decrypted, err := h.crypt.Decrypt(r.Context(), encrypted, "")
if err != nil {
return status.Errorf(codes.Internal, "decrypt code failed: %v", err)
}
code := string(decrypted)
q := fmt.Sprintf("grant_type=authorization_code&redirect_uri=%s&code=%s", url.QueryEscape(h.accept), url.QueryEscape(code))
authZ := "Basic " + base64.StdEncoding.EncodeToString([]byte(a.ClientID+":"+a.ClientSecret))
tokens := &cpb.OidcTokenResponse{}
if err = h.oidcFetch(http.MethodPost, h.tokenURL, q, authZ, "fetch tokens", tokens); err != nil {
return err
}
id := &ga4gh.Identity{Issuer: h.issuerURL}
info, err := translator.FetchUserinfoClaims(r.Context(), h.client, id, tokens.AccessToken, nil)
if err != nil {
return status.Errorf(codes.Unavailable, "fetch user info claims failed: %v", err)
}
if info.Email == "" {
return status.Errorf(codes.Unauthenticated, "user email claim not provided, cannot verify email match")
}
if info.Email != h.item.Email {
return status.Errorf(codes.Unauthenticated, "unexpected user: registered for user %q, got user %q", h.item.Email, id.Email)
}
h.item.AccessToken = tokens.AccessToken
h.item.RefreshToken = tokens.RefreshToken
h.item.UserProfile = map[string]string{
"email": info.Email,
"family_name": info.FamilyName,
"given_name": info.GivenName,
"name": info.Name,
"nickname": info.Nickname,
"subject": info.Subject,
}
return nil
}
func (h *RegisterHandler) oidcFetch(method, url, input, authZ, label string, msg proto.Message) error {
req, err := http.NewRequest(method, url, strings.NewReader(input))
if err != nil {
return status.Errorf(codes.Internal, "%s prepare RPC failed: %v", label, err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", authZ)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := h.client.Do(req)
if err != nil {
return status.Errorf(codes.Unavailable, "%s failed: %v", label, err)
}
if !httputils.IsHTTPSuccess(resp.StatusCode) {
body, _ := ioutil.ReadAll(resp.Body)
str := string(body)
if str == "" {
str = "<empty response>"
}
return status.Errorf(codes.Unavailable, "%s failed (status code %d): %v", label, resp.StatusCode, str)
}
defer resp.Body.Close()
if err = httputils.DecodeJSON(resp.Body, msg); err != nil {
return status.Errorf(codes.Unavailable, "%s decode response failed: %v", label, err)
}
return nil
}
// Post is a POST request.
func (h *RegisterHandler) Post(r *http.Request, name string) (proto.Message, error) {
if name == autoGenerate {
name = uuid.New()
}
email := httputils.QueryParam(r, "email")
if len(email) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "missing email address parameter")
}
if _, err := mail.ParseAddress(email); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid email address %q: %v", email, err)
}
scope := httputils.QueryParamWithDefault(r, "scope", "openid profile email offline")
cat := time.Now()
exp := cat.Add(ttl)
catProto, err := ptypes.TimestampProto(cat)
if err != nil {
return nil, status.Errorf(codes.Internal, "cannot generate iat timestamp: %v", err)
}
expProto, err := ptypes.TimestampProto(exp)
if err != nil {
return nil, status.Errorf(codes.Internal, "cannot generate exp timestamp: %v", err)
}
unique := uuid.New() + "/" + cat.Format(time.RFC3339Nano) + "/" + strconv.FormatUint(rand.Uint64(), 16)
hash := sha3.Sum256([]byte(unique))
secret := hex.EncodeToString(hash[:])
encrypted, err := h.crypt.Encrypt(r.Context(), []byte(secret), "")
if err != nil {
return nil, status.Errorf(codes.Internal, "cannot generate secret: %v", err)
}
a, err := auth.FromContext(r.Context())
if err != nil {
return nil, status.Errorf(codes.Internal, "cannot obtain request context: %v", err)
}
u, err := url.Parse(h.authURL)
if err != nil {
return nil, status.Errorf(codes.Internal, "invalid redirect URL: %v", err)
}
q := u.Query()
q.Set("grant_type", "authorization_code")
q.Set("response_type", "code")
q.Set("client_id", a.ClientID)
q.Set("scope", scope)
q.Set("state", name)
q.Set("redirect_uri", h.accept)
u.RawQuery = q.Encode()
h.save = &cpb.CliState{
Id: name,
Email: email,
EncryptedSecret: encrypted,
ClientId: a.ClientID,
Scope: scope,
AuthUrl: u.String(),
CreatedAt: catProto,
ExpiresAt: expProto,
State: storage.StateActive,
}
// Return the non-encrypted secret whereas `h.save` above will have the secret encrypted.
return &cpb.CliState{
Id: h.save.Id,
Email: h.save.Email,
Secret: secret,
Scope: scope,
AuthUrl: strings.Replace(h.cliAuthURL, "{name}", name, -1),
CreatedAt: catProto,
ExpiresAt: expProto,
}, nil
}
// Put is a PUT request.
func (h *RegisterHandler) Put(r *http.Request, name string) (proto.Message, error) {
return nil, status.Errorf(codes.InvalidArgument, "PUT not allowed")
}
// Patch is a PATCH request.
func (h *RegisterHandler) Patch(r *http.Request, name string) (proto.Message, error) {
return nil, status.Errorf(codes.InvalidArgument, "PATCH not allowed")
}
// Remove is a DELETE request.
func (h *RegisterHandler) Remove(r *http.Request, name string) (proto.Message, error) {
return nil, h.store.DeleteTx(storage.CliAuthDatatype, getRealm(r), storage.DefaultUser, name, storage.LatestRev, h.tx)
}
// CheckIntegrity checks that any modifications make sense before applying them.
func (h *RegisterHandler) CheckIntegrity(*http.Request) *status.Status {
return nil
}
// Save will save any modifications done for the request.
func (h *RegisterHandler) Save(r *http.Request, tx storage.Tx, name string, vars map[string]string, desc, typeName string) error {
if h.save == nil {
return nil
}
id := h.save.Id // don't use "name" to handle autoGenerate case
return h.store.WriteTx(storage.CliAuthDatatype, getRealm(r), storage.DefaultUser, id, storage.LatestRev, h.save, nil, h.tx)
}
////////////////////////////////////////////////////////////
// AuthHandler handles one CLI auth request.
type AuthHandler struct {
auth string
accept string
store storage.Store
}
// NewAuthHandler creates a new AuthHandler.
func NewAuthHandler(store storage.Store) *AuthHandler {
return &AuthHandler{
store: store,
}
}
// Handle handles a CLI authentication request.
func (h *AuthHandler) Handle(w http.ResponseWriter, r *http.Request) {
name := mux.Vars(r)["name"]
item := &cpb.CliState{}
if err := h.store.ReadTx(storage.CliAuthDatatype, getRealm(r), storage.DefaultUser, name, storage.LatestRev, item, nil); err != nil {
if storage.ErrNotFound(err) {
httputils.WriteError(w, status.Errorf(codes.NotFound, "login %q not found", name))
return
}
httputils.WriteError(w, status.Errorf(codes.Unavailable, "load login %q failed: storage is unavailable", name))
return
}
httputils.WriteRedirect(w, r, item.AuthUrl)
}
////////////////////////////////////////////////////////////
// AcceptHandler handles one CLI auth request.
type AcceptHandler struct {
store storage.Store
crypt kms.Encryption
pageTmpl *template.Template
assetPath string
}
// NewAcceptHandler creates a new AcceptHandler.
func NewAcceptHandler(store storage.Store, crypt kms.Encryption, rootPath string) (*AcceptHandler, error) {
tmpl, err := httputils.TemplateFromFiles(cliPageFile)
if err != nil {
return nil, err
}
return &AcceptHandler{
store: store,
crypt: crypt,
pageTmpl: tmpl,
assetPath: path.Join(rootPath, staticPath),
}, nil
}
// Handle handles an accept redirect request.
func (h *AcceptHandler) Handle(w http.ResponseWriter, r *http.Request) {
name := httputils.QueryParam(r, "state")
if name == "" {
// Error state, provide page content to display error (hash messages on page can override).
writeAcceptPage(w, h.pageTmpl, h.assetPath, status.Errorf(codes.InvalidArgument, "missing state parameter"))
return
}
if name == "" {
writeAcceptPage(w, h.pageTmpl, h.assetPath, status.Errorf(codes.InvalidArgument, "login failed: missing state query parameter"))
return
}
item := &cpb.CliState{}
if err := h.store.ReadTx(storage.CliAuthDatatype, getRealm(r), storage.DefaultUser, name, storage.LatestRev, item, nil); err != nil {
if storage.ErrNotFound(err) {
writeAcceptPage(w, h.pageTmpl, h.assetPath, status.Errorf(codes.NotFound, "login %q not found", name))
return
}
writeAcceptPage(w, h.pageTmpl, h.assetPath, status.Errorf(codes.Unavailable, "load login %q failed: storage is unavailable", name))
return
}
// Make sure the item can only be used once by checking if it was accepted previously.
if item.AcceptedAt != nil || len(item.EncryptedCode) > 0 || item.State != storage.StateActive {
if item.State == storage.StateActive {
item.State = storage.StateDisabled
h.store.WriteTx(storage.CliAuthDatatype, getRealm(r), storage.DefaultUser, name, storage.LatestRev, item, nil, nil)
}
writeAcceptPage(w, h.pageTmpl, h.assetPath, status.Errorf(codes.Unauthenticated, "login %q has already been accepted by another login flow", name))
return
}
atProto, err := ptypes.TimestampProto(time.Now())
if err != nil {
writeAcceptPage(w, h.pageTmpl, h.assetPath, status.Errorf(codes.Internal, "login %q cannot generate timestamp: %v", name, err))
return
}
item.AcceptedAt = atProto
nonce := httputils.QueryParam(r, "nonce")
if nonce != "" && nonce != item.Nonce {
writeAcceptPage(w, h.pageTmpl, h.assetPath, status.Errorf(codes.InvalidArgument, "login failed: nonce mismatch"))
return
}
exp, err := ptypes.Timestamp(item.ExpiresAt)
if err != nil {
exp = time.Unix(0, 0)
}
if time.Now().Sub(exp) > 0 {
writeAcceptPage(w, h.pageTmpl, h.assetPath, status.Errorf(codes.DeadlineExceeded, "login %q failed: the login state has expired", name))
return
}
code := httputils.QueryParam(r, "code")
if len(code) == 0 {
item.State = storage.StateDisabled
h.store.WriteTx(storage.CliAuthDatatype, getRealm(r), storage.DefaultUser, name, storage.LatestRev, item, nil, nil)
writeAcceptPage(w, h.pageTmpl, h.assetPath, status.Errorf(codes.InvalidArgument, "login %q failed: no auth code provided", name))
return
}
cryptcode, err := h.crypt.Encrypt(r.Context(), []byte(code), "")
if err != nil {
writeAcceptPage(w, h.pageTmpl, h.assetPath, status.Errorf(codes.Internal, "cannot generate secret: %v", err))
}
item.EncryptedCode = cryptcode
if err := h.store.WriteTx(storage.CliAuthDatatype, getRealm(r), storage.DefaultUser, name, storage.LatestRev, item, nil, nil); err != nil {
writeAcceptPage(w, h.pageTmpl, h.assetPath, status.Errorf(codes.Unavailable, "write login %q failed: storage is unavailable", name))
return
}
writeAcceptPage(w, h.pageTmpl, h.assetPath, nil)
}
////////////////////////////////////////////////////////////
func getRealm(r *http.Request) string {
return storage.DefaultRealm
}
func writeAcceptPage(w http.ResponseWriter, pageTmpl *template.Template, assetPath string, err error) {
code := codes.OK
e := ""
desc := ""
hint := ""
if err != nil {
code = httputils.RPCCode(httputils.FromError(err))
parts := strings.SplitN(err.Error(), ":", 2)
e = code.String()
desc = parts[0]
if len(parts) > 1 {
hint = parts[1]
}
}
args := &cliPageArgs{
AssetDir: assetPath,
Error: e,
Desc: desc,
Hint: hint,
}
pageTmpl.Execute(w, args)
}
type cliPageArgs struct {
AssetDir string
Error string
Desc string
Hint string
}