Skip to content

Commit

Permalink
Added OpenID Connect support (#307)
Browse files Browse the repository at this point in the history
* Added OIDC support

* Changed Dockerfile to multi-stage build

* Reset Makefile to be usable with the new Dockerfile

* Added page that will show the resulting login credentials

* Disabled the go:generate command and the logging of the Version in line 209 since it always made some problems

* Fixed a bug that the authentication server could not verify certificates of the OIDC provider

* Fixed some bugs

* Changed request of refreshing of the session by adding the ClientID and ClientSecret in the HTTP Basic authentication header. Furthermore added some comments.

* Changed whole oidc_auth to use coreos/go-oidc package

* fixed refreshAccessToken due to missing header

* Fixes for Go 1.16

* Fixed Dockerfile

* Fixed small thing in loggings

* Added example in reference.yml

* Delete .idea directory

* adapt to go.mod of original repo

* Update go.mod

* undo stuff of before

Co-authored-by: basels <frederik.basels@rwth-aachen.de>
Co-authored-by: techknowlogick <techknowlogick@gitea.io>
  • Loading branch information
3 people authored Oct 4, 2021
1 parent 9aa1b21 commit cc91cb2
Show file tree
Hide file tree
Showing 8 changed files with 451 additions and 74 deletions.
18 changes: 18 additions & 0 deletions auth_server/authn/data/oidc_auth.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>

<html>
<head>
<meta charset="utf-8">
<title>Docker Registry Authentication</title>
</head>

<body>
<div id="panel">
<p>
<a id="login-with-oidc" href="{{.AuthEndpoint}}?response_type=code&scope=openid%20email&client_id={{.ClientId}}&redirect_uri={{.RedirectURI}}">
Login with OIDC Provider
</a>
</p>
</div>
</body>
</html>
17 changes: 17 additions & 0 deletions auth_server/authn/data/oidc_auth_result.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!doctype html>

<html>
<head>
<meta charset="utf-8">
<title>Docker Registry Authentication</title>
</head>

<body>
<p class="message">
You are successfully authenticated for the Docker Registry.
Use the following username and password to login into the registry:
</p>
<hr>
<pre class="command"><span>$ </span>docker login -u {{.Username}} -p {{.Password}} {{if .RegistryUrl}}{{.RegistryUrl}}{{else}}docker.example.com{{end}}</pre>
</body>
</html>
352 changes: 352 additions & 0 deletions auth_server/authn/oidc_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,352 @@
/*
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 (
"context"
"encoding/json"
"errors"
"fmt"
"golang.org/x/oauth2"
"html/template"
"io/ioutil"
"net/http"
"strings"
"time"

"github.com/coreos/go-oidc/v3/oidc"

"github.com/cesanta/glog"

"github.com/cesanta/docker_auth/auth_server/api"
)

// All configuration options
type OIDCAuthConfig struct {
// --- necessary ---
// URL of the authentication provider. Must be able to serve the /.well-known/openid-configuration
Issuer string `yaml:"issuer,omitempty"`
// URL of the auth server. Has to end with /oidc_auth
RedirectURL string `yaml:"redirect_url,omitempty"`
// ID and secret, priovided by the OIDC provider after registration of the auth server
ClientId string `yaml:"client_id,omitempty"`
ClientSecret string `yaml:"client_secret,omitempty"`
ClientSecretFile string `yaml:"client_secret_file,omitempty"`
// path where the tokendb should be stored within the container
TokenDB string `yaml:"token_db,omitempty"`
// --- optional ---
HTTPTimeout int `yaml:"http_timeout,omitempty"`
// the URL of the docker registry. Used to generate a full docker login command after authentication
RegistryURL string `yaml:"registry_url,omitempty"`
}

// OIDCRefreshTokenResponse is sent by OIDC provider in response to the grant_type=refresh_token request.
type OIDCRefreshTokenResponse struct {
AccessToken string `json:"access_token,omitempty"`
ExpiresIn int64 `json:"expires_in,omitempty"`
TokenType string `json:"token_type,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`

// Returned in case of error.
Error string `json:"error,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}

// ProfileResponse is sent by the /userinfo endpoint or contained in the ID token.
// We use it to validate access token and (re)verify the email address associated with it.
type OIDCProfileResponse struct {
Email string `json:"email,omitempty"`
VerifiedEmail bool `json:"verified_email,omitempty"`
// There are more fields, but we only need email.
}

// The specific OIDC authenticator
type OIDCAuth struct {
config *OIDCAuthConfig
db TokenDB
client *http.Client
tmpl *template.Template
tmplResult *template.Template
ctx context.Context
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
oauth oauth2.Config
}

/*
Creates everything necessary for OIDC auth.
*/
func NewOIDCAuth(c *OIDCAuthConfig) (*OIDCAuth, error) {
db, err := NewTokenDB(c.TokenDB)
if err != nil {
return nil, err
}
glog.Infof("OIDC auth token DB at %s", c.TokenDB)
ctx := context.Background()
oidcAuth, _ := static.ReadFile("data/oidc_auth.tmpl")
oidcAuthResult, _ := static.ReadFile("data/oidc_auth_result.tmpl")

prov, err := oidc.NewProvider(ctx, c.Issuer)
if err != nil {
return nil, err
}
conf := oauth2.Config{
ClientID: c.ClientId,
ClientSecret: c.ClientSecret,
Endpoint: prov.Endpoint(),
RedirectURL: c.RedirectURL,
Scopes: []string{oidc.ScopeOpenID, "email"},
}
return &OIDCAuth{
config: c,
db: db,
client: &http.Client{Timeout: 10 * time.Second},
tmpl: template.Must(template.New("oidc_auth").Parse(string(oidcAuth))),
tmplResult: template.Must(template.New("oidc_auth_result").Parse(string(oidcAuthResult))),
ctx: ctx,
provider: prov,
verifier: prov.Verifier(&oidc.Config{ClientID: conf.ClientID}),
oauth: conf,
}, nil
}

/*
This function will be used by the server if the OIDC auth method is selected. It starts the page for OIDC login or
requests an access token by using the code given by the OIDC provider.
*/
func (ga *OIDCAuth) DoOIDCAuth(rw http.ResponseWriter, req *http.Request) {
code := req.URL.Query().Get("code")
if code != "" {
ga.doOIDCAuthCreateToken(rw, code)
} else if req.Method == "GET" {
ga.doOIDCAuthPage(rw)
} else {
http.Error(rw, "Invalid auth request", http.StatusBadRequest)
}
}

/*
Executes tmpl for the OIDC login page.
*/
func (ga *OIDCAuth) doOIDCAuthPage(rw http.ResponseWriter) {
if err := ga.tmpl.Execute(rw, struct {
AuthEndpoint, RedirectURI, ClientId string
}{
AuthEndpoint: ga.provider.Endpoint().AuthURL,
RedirectURI: ga.oauth.RedirectURL,
ClientId: ga.oauth.ClientID,
}); err != nil {
http.Error(rw, fmt.Sprintf("Template error: %s", err), http.StatusInternalServerError)
}
}

/*
Executes tmplResult for the result of the login process.
*/
func (ga *OIDCAuth) doOIDCAuthResultPage(rw http.ResponseWriter, un string, pw string) {
if err := ga.tmplResult.Execute(rw, struct {
Username, Password, RegistryUrl string
}{
Username: un,
Password: pw,
RegistryUrl: ga.config.RegistryURL,
}); err != nil {
http.Error(rw, fmt.Sprintf("Template error: %s", err), http.StatusInternalServerError)
}
}

/*
Requests an OIDC token by using the code that was provided by the OIDC provider. If it was successfull,
the access token and refresh token is used to create a new token for the users mail address, which is taken from the ID
token.
*/
func (ga *OIDCAuth) doOIDCAuthCreateToken(rw http.ResponseWriter, code string) {

tok, err := ga.oauth.Exchange(ga.ctx, code)
if err != nil {
http.Error(rw, fmt.Sprintf("Error talking to OIDC auth backend: %s", err), http.StatusInternalServerError)
return
}
rawIdTok, ok := tok.Extra("id_token").(string)
if !ok {
http.Error(rw, "No id_token field in oauth2 token.", http.StatusInternalServerError)
return
}
idTok, err := ga.verifier.Verify(ga.ctx, rawIdTok)
if err != nil {
http.Error(rw, fmt.Sprintf("Failed to verify ID token: %s", err), http.StatusInternalServerError)
return
}
var prof OIDCProfileResponse
if err := idTok.Claims(&prof); err != nil {
http.Error(rw, fmt.Sprintf("Failed to get mail information from ID token: %s", err), http.StatusInternalServerError)
return
}
if prof.Email == "" {
http.Error(rw, fmt.Sprintf("No mail information given in ID token"), http.StatusInternalServerError)
return
}

glog.V(2).Infof("New OIDC auth token for %s (Current time: %s, expiration time: %s)", prof.Email, time.Now().String(), tok.Expiry.String())

dbVal := &TokenDBValue{
TokenType: tok.TokenType,
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
ValidUntil: tok.Expiry.Add(time.Duration(-30) * time.Second),
}
dp, err := ga.db.StoreToken(prof.Email, dbVal, 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
}

ga.doOIDCAuthResultPage(rw, prof.Email, dp)
}

/*
Refreshes the access token of the user. Not usable with all OIDC provider, since not all provide refresh tokens.
*/
func (ga *OIDCAuth) refreshAccessToken(refreshToken string) (rtr OIDCRefreshTokenResponse, err error) {

url := ga.provider.Endpoint().TokenURL
pl := strings.NewReader(fmt.Sprintf(
"grant_type=refresh_token&client_id=%s&client_secret=%s&refresh_token=%s",
ga.oauth.ClientID, ga.oauth.ClientSecret, refreshToken))
req, err := http.NewRequest("POST", url, pl)
if err != nil {
err = fmt.Errorf("could not create refresh request: %s", err)
return
}
req.Header.Add("content-type", "application/x-www-form-urlencoded")

resp, err := ga.client.Do(req)
if err != nil {
err = fmt.Errorf("error talking to OIDC 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 {
err = fmt.Errorf("error in reading response of refresh request: %s", err)
return
}
if rtr.Error != "" || rtr.ErrorDescription != "" {
err = fmt.Errorf("%s: %s", rtr.Error, rtr.ErrorDescription)
return
}
return rtr, err
}

/*
In case the DB token is expired, this function uses the refresh token and tries to refresh the access token stored in the
DB. Afterwards, checks if the access token really authenticates the user trying to log in.
*/
func (ga *OIDCAuth) 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 v.RefreshToken == "" {
return nil, errors.New("refresh of your session is not possible. Please sign out and sign in again")
}

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)
}
tokUser, err := ga.provider.UserInfo(ga.ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: v.AccessToken,
TokenType: v.TokenType,
RefreshToken: v.RefreshToken,
Expiry: v.ValidUntil,
}))
if err != nil {
glog.Warningf("Token for %q failed validation: %s", user, err)
return nil, fmt.Errorf("server token invalid: %s", err)
}
if tokUser.Email != user {
glog.Errorf("token for wrong user: expected %s, found %s", user, tokUser.Email)
return nil, fmt.Errorf("found token for wrong user")
}
texp := v.ValidUntil.Sub(time.Now())
glog.V(1).Infof("Validated OIDC auth token for %s (exp %d)", user, int(texp.Seconds()))
return v, nil
}

/*
First checks if OIDC token is valid. Then delete the corresponding DB token from the database. The user is now signed out
Not deleted because maybe it will be implemented in the future.
*/
//func (ga *OIDCAuth) doOIDCAuthSignOut(rw http.ResponseWriter, token string) {
// // Authenticate web user.
// ui, err := ga.validateIDToken(token)
// if err != nil || ui == ""{
// http.Error(rw, fmt.Sprintf("Could not verify user token: %s", err), http.StatusBadRequest)
// return
// }
// err = ga.db.DeleteToken(ui)
// if err != nil {
// glog.Error(err)
// }
// fmt.Fprint(rw, "signed out")
//}

/*
Called by server. Authenticates user with credentials that were given in the docker login command. If the token in the
DB is expired, the OIDC access token is validated and, if possible, refreshed.
*/
func (ga *OIDCAuth) 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 *OIDCAuth) Stop() {
err := ga.db.Close()
if err != nil {
glog.Info("Problems at closing the token DB")
} else {
glog.Info("Token DB closed")
}
}

func (ga *OIDCAuth) Name() string {
return "OpenID Connect"
}
Loading

0 comments on commit cc91cb2

Please sign in to comment.