Skip to content

Commit cc91cb2

Browse files
FBaselsbaselstechknowlogick
authored
Added OpenID Connect support (#307)
* 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>
1 parent 9aa1b21 commit cc91cb2

File tree

8 files changed

+451
-74
lines changed

8 files changed

+451
-74
lines changed

auth_server/authn/data/oidc_auth.tmpl

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<!doctype html>
2+
3+
<html>
4+
<head>
5+
<meta charset="utf-8">
6+
<title>Docker Registry Authentication</title>
7+
</head>
8+
9+
<body>
10+
<div id="panel">
11+
<p>
12+
<a id="login-with-oidc" href="{{.AuthEndpoint}}?response_type=code&scope=openid%20email&client_id={{.ClientId}}&redirect_uri={{.RedirectURI}}">
13+
Login with OIDC Provider
14+
</a>
15+
</p>
16+
</div>
17+
</body>
18+
</html>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<!doctype html>
2+
3+
<html>
4+
<head>
5+
<meta charset="utf-8">
6+
<title>Docker Registry Authentication</title>
7+
</head>
8+
9+
<body>
10+
<p class="message">
11+
You are successfully authenticated for the Docker Registry.
12+
Use the following username and password to login into the registry:
13+
</p>
14+
<hr>
15+
<pre class="command"><span>$ </span>docker login -u {{.Username}} -p {{.Password}} {{if .RegistryUrl}}{{.RegistryUrl}}{{else}}docker.example.com{{end}}</pre>
16+
</body>
17+
</html>

auth_server/authn/oidc_auth.go

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
/*
2+
Copyright 2015 Cesanta Software Ltd.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
https://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package authn
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"errors"
23+
"fmt"
24+
"golang.org/x/oauth2"
25+
"html/template"
26+
"io/ioutil"
27+
"net/http"
28+
"strings"
29+
"time"
30+
31+
"github.com/coreos/go-oidc/v3/oidc"
32+
33+
"github.com/cesanta/glog"
34+
35+
"github.com/cesanta/docker_auth/auth_server/api"
36+
)
37+
38+
// All configuration options
39+
type OIDCAuthConfig struct {
40+
// --- necessary ---
41+
// URL of the authentication provider. Must be able to serve the /.well-known/openid-configuration
42+
Issuer string `yaml:"issuer,omitempty"`
43+
// URL of the auth server. Has to end with /oidc_auth
44+
RedirectURL string `yaml:"redirect_url,omitempty"`
45+
// ID and secret, priovided by the OIDC provider after registration of the auth server
46+
ClientId string `yaml:"client_id,omitempty"`
47+
ClientSecret string `yaml:"client_secret,omitempty"`
48+
ClientSecretFile string `yaml:"client_secret_file,omitempty"`
49+
// path where the tokendb should be stored within the container
50+
TokenDB string `yaml:"token_db,omitempty"`
51+
// --- optional ---
52+
HTTPTimeout int `yaml:"http_timeout,omitempty"`
53+
// the URL of the docker registry. Used to generate a full docker login command after authentication
54+
RegistryURL string `yaml:"registry_url,omitempty"`
55+
}
56+
57+
// OIDCRefreshTokenResponse is sent by OIDC provider in response to the grant_type=refresh_token request.
58+
type OIDCRefreshTokenResponse struct {
59+
AccessToken string `json:"access_token,omitempty"`
60+
ExpiresIn int64 `json:"expires_in,omitempty"`
61+
TokenType string `json:"token_type,omitempty"`
62+
RefreshToken string `json:"refresh_token,omitempty"`
63+
64+
// Returned in case of error.
65+
Error string `json:"error,omitempty"`
66+
ErrorDescription string `json:"error_description,omitempty"`
67+
}
68+
69+
// ProfileResponse is sent by the /userinfo endpoint or contained in the ID token.
70+
// We use it to validate access token and (re)verify the email address associated with it.
71+
type OIDCProfileResponse struct {
72+
Email string `json:"email,omitempty"`
73+
VerifiedEmail bool `json:"verified_email,omitempty"`
74+
// There are more fields, but we only need email.
75+
}
76+
77+
// The specific OIDC authenticator
78+
type OIDCAuth struct {
79+
config *OIDCAuthConfig
80+
db TokenDB
81+
client *http.Client
82+
tmpl *template.Template
83+
tmplResult *template.Template
84+
ctx context.Context
85+
provider *oidc.Provider
86+
verifier *oidc.IDTokenVerifier
87+
oauth oauth2.Config
88+
}
89+
90+
/*
91+
Creates everything necessary for OIDC auth.
92+
*/
93+
func NewOIDCAuth(c *OIDCAuthConfig) (*OIDCAuth, error) {
94+
db, err := NewTokenDB(c.TokenDB)
95+
if err != nil {
96+
return nil, err
97+
}
98+
glog.Infof("OIDC auth token DB at %s", c.TokenDB)
99+
ctx := context.Background()
100+
oidcAuth, _ := static.ReadFile("data/oidc_auth.tmpl")
101+
oidcAuthResult, _ := static.ReadFile("data/oidc_auth_result.tmpl")
102+
103+
prov, err := oidc.NewProvider(ctx, c.Issuer)
104+
if err != nil {
105+
return nil, err
106+
}
107+
conf := oauth2.Config{
108+
ClientID: c.ClientId,
109+
ClientSecret: c.ClientSecret,
110+
Endpoint: prov.Endpoint(),
111+
RedirectURL: c.RedirectURL,
112+
Scopes: []string{oidc.ScopeOpenID, "email"},
113+
}
114+
return &OIDCAuth{
115+
config: c,
116+
db: db,
117+
client: &http.Client{Timeout: 10 * time.Second},
118+
tmpl: template.Must(template.New("oidc_auth").Parse(string(oidcAuth))),
119+
tmplResult: template.Must(template.New("oidc_auth_result").Parse(string(oidcAuthResult))),
120+
ctx: ctx,
121+
provider: prov,
122+
verifier: prov.Verifier(&oidc.Config{ClientID: conf.ClientID}),
123+
oauth: conf,
124+
}, nil
125+
}
126+
127+
/*
128+
This function will be used by the server if the OIDC auth method is selected. It starts the page for OIDC login or
129+
requests an access token by using the code given by the OIDC provider.
130+
*/
131+
func (ga *OIDCAuth) DoOIDCAuth(rw http.ResponseWriter, req *http.Request) {
132+
code := req.URL.Query().Get("code")
133+
if code != "" {
134+
ga.doOIDCAuthCreateToken(rw, code)
135+
} else if req.Method == "GET" {
136+
ga.doOIDCAuthPage(rw)
137+
} else {
138+
http.Error(rw, "Invalid auth request", http.StatusBadRequest)
139+
}
140+
}
141+
142+
/*
143+
Executes tmpl for the OIDC login page.
144+
*/
145+
func (ga *OIDCAuth) doOIDCAuthPage(rw http.ResponseWriter) {
146+
if err := ga.tmpl.Execute(rw, struct {
147+
AuthEndpoint, RedirectURI, ClientId string
148+
}{
149+
AuthEndpoint: ga.provider.Endpoint().AuthURL,
150+
RedirectURI: ga.oauth.RedirectURL,
151+
ClientId: ga.oauth.ClientID,
152+
}); err != nil {
153+
http.Error(rw, fmt.Sprintf("Template error: %s", err), http.StatusInternalServerError)
154+
}
155+
}
156+
157+
/*
158+
Executes tmplResult for the result of the login process.
159+
*/
160+
func (ga *OIDCAuth) doOIDCAuthResultPage(rw http.ResponseWriter, un string, pw string) {
161+
if err := ga.tmplResult.Execute(rw, struct {
162+
Username, Password, RegistryUrl string
163+
}{
164+
Username: un,
165+
Password: pw,
166+
RegistryUrl: ga.config.RegistryURL,
167+
}); err != nil {
168+
http.Error(rw, fmt.Sprintf("Template error: %s", err), http.StatusInternalServerError)
169+
}
170+
}
171+
172+
/*
173+
Requests an OIDC token by using the code that was provided by the OIDC provider. If it was successfull,
174+
the access token and refresh token is used to create a new token for the users mail address, which is taken from the ID
175+
token.
176+
*/
177+
func (ga *OIDCAuth) doOIDCAuthCreateToken(rw http.ResponseWriter, code string) {
178+
179+
tok, err := ga.oauth.Exchange(ga.ctx, code)
180+
if err != nil {
181+
http.Error(rw, fmt.Sprintf("Error talking to OIDC auth backend: %s", err), http.StatusInternalServerError)
182+
return
183+
}
184+
rawIdTok, ok := tok.Extra("id_token").(string)
185+
if !ok {
186+
http.Error(rw, "No id_token field in oauth2 token.", http.StatusInternalServerError)
187+
return
188+
}
189+
idTok, err := ga.verifier.Verify(ga.ctx, rawIdTok)
190+
if err != nil {
191+
http.Error(rw, fmt.Sprintf("Failed to verify ID token: %s", err), http.StatusInternalServerError)
192+
return
193+
}
194+
var prof OIDCProfileResponse
195+
if err := idTok.Claims(&prof); err != nil {
196+
http.Error(rw, fmt.Sprintf("Failed to get mail information from ID token: %s", err), http.StatusInternalServerError)
197+
return
198+
}
199+
if prof.Email == "" {
200+
http.Error(rw, fmt.Sprintf("No mail information given in ID token"), http.StatusInternalServerError)
201+
return
202+
}
203+
204+
glog.V(2).Infof("New OIDC auth token for %s (Current time: %s, expiration time: %s)", prof.Email, time.Now().String(), tok.Expiry.String())
205+
206+
dbVal := &TokenDBValue{
207+
TokenType: tok.TokenType,
208+
AccessToken: tok.AccessToken,
209+
RefreshToken: tok.RefreshToken,
210+
ValidUntil: tok.Expiry.Add(time.Duration(-30) * time.Second),
211+
}
212+
dp, err := ga.db.StoreToken(prof.Email, dbVal, true)
213+
if err != nil {
214+
glog.Errorf("Failed to record server token: %s", err)
215+
http.Error(rw, "Failed to record server token: %s", http.StatusInternalServerError)
216+
return
217+
}
218+
219+
ga.doOIDCAuthResultPage(rw, prof.Email, dp)
220+
}
221+
222+
/*
223+
Refreshes the access token of the user. Not usable with all OIDC provider, since not all provide refresh tokens.
224+
*/
225+
func (ga *OIDCAuth) refreshAccessToken(refreshToken string) (rtr OIDCRefreshTokenResponse, err error) {
226+
227+
url := ga.provider.Endpoint().TokenURL
228+
pl := strings.NewReader(fmt.Sprintf(
229+
"grant_type=refresh_token&client_id=%s&client_secret=%s&refresh_token=%s",
230+
ga.oauth.ClientID, ga.oauth.ClientSecret, refreshToken))
231+
req, err := http.NewRequest("POST", url, pl)
232+
if err != nil {
233+
err = fmt.Errorf("could not create refresh request: %s", err)
234+
return
235+
}
236+
req.Header.Add("content-type", "application/x-www-form-urlencoded")
237+
238+
resp, err := ga.client.Do(req)
239+
if err != nil {
240+
err = fmt.Errorf("error talking to OIDC auth backend: %s", err)
241+
return
242+
}
243+
respStr, _ := ioutil.ReadAll(resp.Body)
244+
glog.V(2).Infof("Refresh token resp: %s", strings.Replace(string(respStr), "\n", " ", -1))
245+
246+
err = json.Unmarshal(respStr, &rtr)
247+
if err != nil {
248+
err = fmt.Errorf("error in reading response of refresh request: %s", err)
249+
return
250+
}
251+
if rtr.Error != "" || rtr.ErrorDescription != "" {
252+
err = fmt.Errorf("%s: %s", rtr.Error, rtr.ErrorDescription)
253+
return
254+
}
255+
return rtr, err
256+
}
257+
258+
/*
259+
In case the DB token is expired, this function uses the refresh token and tries to refresh the access token stored in the
260+
DB. Afterwards, checks if the access token really authenticates the user trying to log in.
261+
*/
262+
func (ga *OIDCAuth) validateServerToken(user string) (*TokenDBValue, error) {
263+
v, err := ga.db.GetValue(user)
264+
if err != nil || v == nil {
265+
if err == nil {
266+
err = errors.New("no db value, please sign out and sign in again")
267+
}
268+
return nil, err
269+
}
270+
if v.RefreshToken == "" {
271+
return nil, errors.New("refresh of your session is not possible. Please sign out and sign in again")
272+
}
273+
274+
glog.V(2).Infof("Refreshing token for %s", user)
275+
rtr, err := ga.refreshAccessToken(v.RefreshToken)
276+
if err != nil {
277+
glog.Warningf("Failed to refresh token for %q: %s", user, err)
278+
return nil, fmt.Errorf("failed to refresh token: %s", err)
279+
}
280+
v.AccessToken = rtr.AccessToken
281+
v.ValidUntil = time.Now().Add(time.Duration(rtr.ExpiresIn-30) * time.Second)
282+
glog.Infof("Refreshed auth token for %s (exp %d)", user, rtr.ExpiresIn)
283+
_, err = ga.db.StoreToken(user, v, false)
284+
if err != nil {
285+
glog.Errorf("Failed to record refreshed token: %s", err)
286+
return nil, fmt.Errorf("failed to record refreshed token: %s", err)
287+
}
288+
tokUser, err := ga.provider.UserInfo(ga.ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: v.AccessToken,
289+
TokenType: v.TokenType,
290+
RefreshToken: v.RefreshToken,
291+
Expiry: v.ValidUntil,
292+
}))
293+
if err != nil {
294+
glog.Warningf("Token for %q failed validation: %s", user, err)
295+
return nil, fmt.Errorf("server token invalid: %s", err)
296+
}
297+
if tokUser.Email != user {
298+
glog.Errorf("token for wrong user: expected %s, found %s", user, tokUser.Email)
299+
return nil, fmt.Errorf("found token for wrong user")
300+
}
301+
texp := v.ValidUntil.Sub(time.Now())
302+
glog.V(1).Infof("Validated OIDC auth token for %s (exp %d)", user, int(texp.Seconds()))
303+
return v, nil
304+
}
305+
306+
/*
307+
First checks if OIDC token is valid. Then delete the corresponding DB token from the database. The user is now signed out
308+
Not deleted because maybe it will be implemented in the future.
309+
*/
310+
//func (ga *OIDCAuth) doOIDCAuthSignOut(rw http.ResponseWriter, token string) {
311+
// // Authenticate web user.
312+
// ui, err := ga.validateIDToken(token)
313+
// if err != nil || ui == ""{
314+
// http.Error(rw, fmt.Sprintf("Could not verify user token: %s", err), http.StatusBadRequest)
315+
// return
316+
// }
317+
// err = ga.db.DeleteToken(ui)
318+
// if err != nil {
319+
// glog.Error(err)
320+
// }
321+
// fmt.Fprint(rw, "signed out")
322+
//}
323+
324+
/*
325+
Called by server. Authenticates user with credentials that were given in the docker login command. If the token in the
326+
DB is expired, the OIDC access token is validated and, if possible, refreshed.
327+
*/
328+
func (ga *OIDCAuth) Authenticate(user string, password api.PasswordString) (bool, api.Labels, error) {
329+
err := ga.db.ValidateToken(user, password)
330+
if err == ExpiredToken {
331+
_, err = ga.validateServerToken(user)
332+
if err != nil {
333+
return false, nil, err
334+
}
335+
} else if err != nil {
336+
return false, nil, err
337+
}
338+
return true, nil, nil
339+
}
340+
341+
func (ga *OIDCAuth) Stop() {
342+
err := ga.db.Close()
343+
if err != nil {
344+
glog.Info("Problems at closing the token DB")
345+
} else {
346+
glog.Info("Token DB closed")
347+
}
348+
}
349+
350+
func (ga *OIDCAuth) Name() string {
351+
return "OpenID Connect"
352+
}

0 commit comments

Comments
 (0)