Auto-login from trusted reverse-proxy Authentik headers
When SSO_GATEWAY_SECRET is set and an incoming request carries both X-Artifacto-Gateway (matching the secret) and X-Authentik-Username, the admin middleware mints a session automatically so Authentik-authenticated users skip the password form. Missing or wrong gateway header falls back to the password-login flow, so peers that can reach the container directly (bypassing the reverse proxy) cannot spoof Authentik identities.
This commit is contained in:
@@ -15,8 +15,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
AdminCookie = "artifacto_admin"
|
||||
adminLifetime = 30 * 24 * time.Hour
|
||||
AdminCookie = "artifacto_admin"
|
||||
adminLifetime = 30 * 24 * time.Hour
|
||||
ssoGatewayHeader = "X-Artifacto-Gateway"
|
||||
ssoUsernameHeader = "X-Authentik-Username"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
@@ -26,10 +28,11 @@ const ctxAdmin ctxKey = 1
|
||||
type Admin struct {
|
||||
passwordHash []byte
|
||||
secret []byte
|
||||
ssoSecret []byte
|
||||
secure bool
|
||||
}
|
||||
|
||||
func NewAdmin(password, secretHex string, secure bool) (*Admin, error) {
|
||||
func NewAdmin(password, secretHex, ssoSecret string, secure bool) (*Admin, error) {
|
||||
if password == "" {
|
||||
return nil, errors.New("ADMIN_PASSWORD required")
|
||||
}
|
||||
@@ -40,7 +43,11 @@ func NewAdmin(password, secretHex string, secure bool) (*Admin, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Admin{passwordHash: h, secret: []byte(secretHex), secure: secure}, nil
|
||||
a := &Admin{passwordHash: h, secret: []byte(secretHex), secure: secure}
|
||||
if ssoSecret != "" {
|
||||
a.ssoSecret = []byte(ssoSecret)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *Admin) VerifyPassword(p string) bool {
|
||||
@@ -99,21 +106,41 @@ func (a *Admin) ClearCookie(w http.ResponseWriter) {
|
||||
})
|
||||
}
|
||||
|
||||
// Middleware allows the request through if the admin cookie is valid; otherwise
|
||||
// redirects to /login (for HTML nav) or returns 401 (for API/HTMX).
|
||||
// HasValidSSO reports whether the request carries an Authentik-forwarded identity
|
||||
// from a trusted gateway. The shared gateway-secret header prevents spoofing by
|
||||
// peers that can reach the container directly (bypassing the reverse proxy).
|
||||
func (a *Admin) HasValidSSO(r *http.Request) bool {
|
||||
if len(a.ssoSecret) == 0 {
|
||||
return false
|
||||
}
|
||||
gw := r.Header.Get(ssoGatewayHeader)
|
||||
if gw == "" || !hmac.Equal([]byte(gw), a.ssoSecret) {
|
||||
return false
|
||||
}
|
||||
return r.Header.Get(ssoUsernameHeader) != ""
|
||||
}
|
||||
|
||||
// Middleware allows the request through if the admin cookie is valid or a
|
||||
// trusted Authentik SSO header is present; otherwise redirects to /login
|
||||
// (for HTML nav) or returns 401 (for API/HTMX).
|
||||
func (a *Admin) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(AdminCookie)
|
||||
if err != nil || !a.Verify(c.Value) {
|
||||
if isAPI(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login?next="+r.URL.RequestURI(), http.StatusSeeOther)
|
||||
if c, err := r.Cookie(AdminCookie); err == nil && a.Verify(c.Value) {
|
||||
ctx := context.WithValue(r.Context(), ctxAdmin, true)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxAdmin, true)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
if a.HasValidSSO(r) {
|
||||
a.SetCookie(w)
|
||||
ctx := context.WithValue(r.Context(), ctxAdmin, true)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
if isAPI(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login?next="+r.URL.RequestURI(), http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user