blob: b5a3b8a5df47e5530bdb0a3254a76c15a615c056 [file] [log] [blame]
/*
Copyright 2015 Cesanta Software Ltd.
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
https://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 authn
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"io/ioutil"
"net/http"
"net/url"
"strings"
"time"
"github.com/cesanta/glog"
"github.com/cesanta/docker_auth/auth_server/api"
)
type GoogleAuthConfig struct {
Domain string `yaml:"domain,omitempty"`
ClientId string `yaml:"client_id,omitempty"`
ClientSecret string `yaml:"client_secret,omitempty"`
ClientSecretFile string `yaml:"client_secret_file,omitempty"`
TokenDB string `yaml:"token_db,omitempty"`
HTTPTimeout int `yaml:"http_timeout,omitempty"`
}
type GoogleAuthRequest struct {
Action string `json:"action,omitempty"`
Code string `json:"code,omitempty"`
Token string `json:"token,omitempty"`
}
// From github.com/google-api-go-client/oauth2/v2/oauth2-gen.go
type GoogleTokenInfo struct {
// AccessType: The access type granted with this token. It can be
// offline or online.
AccessType string `json:"access_type,omitempty"`
// Audience: Who is the intended audience for this token. In general the
// same as issued_to.
Audience string `json:"audience,omitempty"`
// Email: The email address of the user. Present only if the email scope
// is present in the request.
Email string `json:"email,omitempty"`
// ExpiresIn: The expiry time of the token, as number of seconds left
// until expiry.
ExpiresIn int64 `json:"expires_in,omitempty"`
// IssuedTo: To whom was the token issued to. In general the same as
// audience.
IssuedTo string `json:"issued_to,omitempty"`
// Scope: The space separated list of scopes granted to this token.
Scope string `json:"scope,omitempty"`
// TokenHandle: The token handle associated with this token.
TokenHandle string `json:"token_handle,omitempty"`
// UserId: The obfuscated user id.
UserId string `json:"user_id,omitempty"`
// VerifiedEmail: Boolean flag which is true if the email address is
// verified. Present only if the email scope is present in the request.
VerifiedEmail bool `json:"verified_email,omitempty"`
// Returned in case of error.
Error string `json:"error,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// CodeToTokenResponse is sent by Google servers in response to the grant_type=authorization_code request.
type CodeToTokenResponse struct {
IDToken string `json:"id_token,omitempty"`
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresIn int64 `json:"expires_in,omitempty"`
TokenType string `json:"token_type,omitempty"`
// Returned in case of error.
Error string `json:"error,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// CodeToTokenResponse is sent by Google servers in response to the grant_type=refresh_token request.
type RefreshTokenResponse struct {
AccessToken string `json:"access_token,omitempty"`
ExpiresIn int64 `json:"expires_in,omitempty"`
TokenType string `json:"token_type,omitempty"`
// Returned in case of error.
Error string `json:"error,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// ProfileResponse is sent by the /userinfo/v2/me endpoint.
// We use it to validate access token and (re)verify the email address associated with it.
type ProfileResponse struct {
Email string `json:"email,omitempty"`
VerifiedEmail bool `json:"verified_email,omitempty"`
// There are more fields, but we only need email.
}
type GoogleAuth struct {
config *GoogleAuthConfig
db TokenDB
client *http.Client
tmpl *template.Template
}
func NewGoogleAuth(c *GoogleAuthConfig) (*GoogleAuth, error) {
db, err := NewTokenDB(c.TokenDB)
if err != nil {
return nil, err
}
glog.Infof("Google auth token DB at %s", c.TokenDB)
return &GoogleAuth{
config: c,
db: db,
client: &http.Client{Timeout: 10 * time.Second},
tmpl: template.Must(template.New("google_auth").Parse(string(MustAsset("data/google_auth.tmpl")))),
}, nil
}
func (ga *GoogleAuth) DoGoogleAuth(rw http.ResponseWriter, req *http.Request) {
if req.Method == "GET" {
ga.doGoogleAuthPage(rw, req)
return
}
gauthRequest, _ := ioutil.ReadAll(req.Body)
glog.V(2).Infof("gauth request: %s", string(gauthRequest))
var gar GoogleAuthRequest
err := json.Unmarshal(gauthRequest, &gar)
if err != nil {
http.Error(rw, "Invalid auth request", http.StatusBadRequest)
return
}
switch {
case gar.Action == "sign_in" && gar.Code != "":
ga.doGoogleAuthCreateToken(rw, gar.Code)
case gar.Action == "check" && gar.Token != "":
ga.doGoogleAuthCheck(rw, gar.Token)
case gar.Action == "sign_out" && gar.Token != "":
ga.doGoogleAuthSignOut(rw, gar.Token)
default:
http.Error(rw, "Invalid auth request", http.StatusBadRequest)
}
}
func (ga *GoogleAuth) doGoogleAuthPage(rw http.ResponseWriter, req *http.Request) {
if err := ga.tmpl.Execute(rw, struct{ ClientId string }{ClientId: ga.config.ClientId}); err != nil {
http.Error(rw, fmt.Sprintf("Template error: %s", err), http.StatusInternalServerError)
}
}
// https://developers.google.com/identity/protocols/OAuth2WebServer#handlingtheresponse
func (ga *GoogleAuth) doGoogleAuthCreateToken(rw http.ResponseWriter, code string) {
resp, err := ga.client.PostForm(
"https://www.googleapis.com/oauth2/v3/token",
url.Values{
"code": []string{string(code)},
"client_id": []string{ga.config.ClientId},
"client_secret": []string{ga.config.ClientSecret},
"redirect_uri": []string{"postmessage"},
"grant_type": []string{"authorization_code"},
})
if err != nil {
http.Error(rw, fmt.Sprintf("Error talking to Google auth backend: %s", err), http.StatusServiceUnavailable)
return
}
codeResp, _ := ioutil.ReadAll(resp.Body)
resp.Body.Close()
glog.V(2).Infof("Code to token resp: %s", strings.Replace(string(codeResp), "\n", " ", -1))
var c2t CodeToTokenResponse
err = json.Unmarshal(codeResp, &c2t)
if err != nil || c2t.Error != "" || c2t.ErrorDescription != "" {
var et string
if err != nil {
et = err.Error()
} else {
et = fmt.Sprintf("%s: %s", c2t.Error, c2t.ErrorDescription)
}
http.Error(rw, fmt.Sprintf("Failed to get token: %s", et), http.StatusBadRequest)
return
}
if c2t.RefreshToken == "" {
http.Error(rw, "Google did not return refresh token, please sign out and sign in again.", http.StatusBadRequest)
return
}
if c2t.ExpiresIn < 60 {
http.Error(rw, "New token is too short-lived", http.StatusInternalServerError)
return
}
ti, err := ga.getIDTokenInfo(c2t.IDToken)
if err != nil {
glog.Errorf("Newly-acquired token is invalid: %+v %s", c2t, err)
http.Error(rw, "Newly-acquired token is invalid", http.StatusInternalServerError)
return
}
user := ti.Email
glog.Infof("New Google auth token for %s (exp %d)", user, c2t.ExpiresIn)
v := &TokenDBValue{
TokenType: c2t.TokenType,
AccessToken: c2t.AccessToken,
RefreshToken: c2t.RefreshToken,
ValidUntil: time.Now().Add(time.Duration(c2t.ExpiresIn-30) * time.Second),
}
dp, err := ga.db.StoreToken(user, v, true)
if err != nil {
glog.Errorf("Failed to record server token: %s", err)
http.Error(rw, "Failed to record server token: %s", http.StatusInternalServerError)
return
}
fmt.Fprintf(rw, `Server logged in; now run "docker login YOUR_REGISTRY_FQDN", use %s as login and %s as password.`, user, dp)
}
func (ga *GoogleAuth) getIDTokenInfo(token string) (*GoogleTokenInfo, error) {
// There is no Go auth library yet, using the tokeninfo endpoint.
resp, err := http.Get(fmt.Sprintf("https://www.googleapis.com/oauth2/v2/tokeninfo?id_token=%s", token))
if err != nil {
return nil, fmt.Errorf("could not verify token %s: %s", token, err)
}
body, _ := ioutil.ReadAll(resp.Body)
resp.Body.Close()
var ti GoogleTokenInfo
err = json.Unmarshal(body, &ti)
if err != nil {
return nil, fmt.Errorf("could not unmarshal token info %q: %s", string(body), err)
}
glog.V(2).Infof("Token info: %+v", strings.Replace(string(body), "\n", " ", -1))
if ti.Error != "" || ti.ErrorDescription != "" {
return nil, fmt.Errorf("bad token %q: %s %s", token, ti.Error, ti.ErrorDescription)
}
if ti.ExpiresIn <= 0 {
return nil, errors.New("expired token")
}
me := ga.config.ClientId
if ti.Audience != me {
return nil, fmt.Errorf("token intended for %s, not %s", ti.Audience, me)
}
if ti.Email == "" || !ti.VerifiedEmail {
return nil, errors.New("no verified email in token")
}
err = ga.checkDomain(ti.Email)
if err != nil {
return nil, err
}
glog.V(2).Infof("Token for %s, expires in %d", ti.Email, ti.ExpiresIn)
return &ti, nil
}
func (ga *GoogleAuth) checkDomain(email string) error {
if ga.config.Domain == "" {
return nil
}
parts := strings.Split(email, "@")
if parts[1] != ga.config.Domain {
return fmt.Errorf("only users from %s may login", ga.config.Domain)
}
return nil
}
// https://developers.google.com/identity/protocols/OAuth2WebServer#refresh
func (ga *GoogleAuth) refreshAccessToken(refreshToken string) (rtr RefreshTokenResponse, err error) {
resp, err := ga.client.PostForm(
"https://www.googleapis.com/oauth2/v3/token",
url.Values{
"refresh_token": []string{refreshToken},
"client_id": []string{ga.config.ClientId},
"client_secret": []string{ga.config.ClientSecret},
"grant_type": []string{"refresh_token"},
})
if err != nil {
err = fmt.Errorf("Error talking to Google auth backend: %s", err)
return
}
respStr, _ := ioutil.ReadAll(resp.Body)
glog.V(2).Infof("Refresh token resp: %s", strings.Replace(string(respStr), "\n", " ", -1))
err = json.Unmarshal(respStr, &rtr)
if err == nil && rtr.Error != "" || rtr.ErrorDescription != "" {
err = fmt.Errorf("%s: %s", rtr.Error, rtr.ErrorDescription)
}
return
}
func (ga *GoogleAuth) validateAccessToken(toktype, token string) (user string, err error) {
req, _ := http.NewRequest("GET", "https://www.googleapis.com/userinfo/v2/me", nil)
req.Header.Add("Authorization", fmt.Sprintf("%s %s", toktype, token))
resp, err := ga.client.Do(req)
if err != nil {
return
}
respStr, _ := ioutil.ReadAll(resp.Body)
glog.V(2).Infof("Access token validation rrsponse: %s", strings.Replace(string(respStr), "\n", " ", -1))
var pr ProfileResponse
err = json.Unmarshal(respStr, &pr)
if err != nil {
return
}
err = ga.checkDomain(pr.Email)
if err != nil {
return
}
return pr.Email, nil
}
func (ga *GoogleAuth) validateServerToken(user string) (*TokenDBValue, error) {
v, err := ga.db.GetValue(user)
if err != nil || v == nil {
if err == nil {
err = errors.New("no db value, please sign out and sign in again.")
}
return nil, err
}
if time.Now().After(v.ValidUntil) {
glog.V(2).Infof("Refreshing token for %s", user)
rtr, err := ga.refreshAccessToken(v.RefreshToken)
if err != nil {
glog.Warningf("Failed to refresh token for %q: %s", user, err)
return nil, fmt.Errorf("failed to refresh token: %s", err)
}
v.AccessToken = rtr.AccessToken
v.ValidUntil = time.Now().Add(time.Duration(rtr.ExpiresIn-30) * time.Second)
glog.Infof("Refreshed auth token for %s (exp %d)", user, rtr.ExpiresIn)
_, err = ga.db.StoreToken(user, v, false)
if err != nil {
glog.Errorf("Failed to record refreshed token: %s", err)
return nil, fmt.Errorf("failed to record refreshed token: %s", err)
}
}
tokenUser, err := ga.validateAccessToken(v.TokenType, v.AccessToken)
if err != nil {
glog.Warningf("Token for %q failed validation: %s", user, err)
return nil, fmt.Errorf("server token invalid: %s", err)
}
if tokenUser != user {
glog.Errorf("token for wrong user: expected %s, found %s", user, tokenUser)
return nil, fmt.Errorf("found token for wrong user")
}
texp := v.ValidUntil.Sub(time.Now())
glog.V(1).Infof("Validated Google auth token for %s (exp %d)", user, int(texp.Seconds()))
return v, nil
}
func (ga *GoogleAuth) doGoogleAuthCheck(rw http.ResponseWriter, token string) {
// First, authenticate web user.
ti, err := ga.getIDTokenInfo(token)
if err != nil {
http.Error(rw, fmt.Sprintf("Could not verify user token: %s", err), http.StatusBadRequest)
return
}
// User authenticated, now verify our token.
dbv, err := ga.validateServerToken(ti.Email)
if err != nil {
http.Error(rw, fmt.Sprintf("Could not verify server token: %s", err), http.StatusBadRequest)
return
}
// Truncate to seconds for presentation.
texp := time.Duration(int64(dbv.ValidUntil.Sub(time.Now()).Seconds())) * time.Second
fmt.Fprintf(rw, "Server token for %s validated, expires in %s", ti.Email, texp)
}
func (ga *GoogleAuth) doGoogleAuthSignOut(rw http.ResponseWriter, token string) {
// Authenticate web user.
ti, err := ga.getIDTokenInfo(token)
if err != nil {
http.Error(rw, fmt.Sprintf("Could not verify user token: %s", err), http.StatusBadRequest)
return
}
err = ga.db.DeleteToken(ti.Email)
if err != nil {
glog.Error(err)
}
fmt.Fprint(rw, "signed out")
}
func (ga *GoogleAuth) Authenticate(user string, password api.PasswordString) (bool, api.Labels, error) {
err := ga.db.ValidateToken(user, password)
if err == ExpiredToken {
_, err = ga.validateServerToken(user)
if err != nil {
return false, nil, err
}
} else if err != nil {
return false, nil, err
}
return true, nil, nil
}
func (ga *GoogleAuth) Stop() {
ga.db.Close()
glog.Info("Token DB closed")
}
func (ga *GoogleAuth) Name() string {
return "Google"
}