| /* |
| 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" |
| } |