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:
@@ -1,3 +1,10 @@
|
|||||||
ADMIN_PASSWORD=change-me
|
ADMIN_PASSWORD=change-me
|
||||||
SESSION_SECRET=generate-with-openssl-rand-hex-32
|
SESSION_SECRET=generate-with-openssl-rand-hex-32
|
||||||
BASE_URL=https://artifacto.hubris.network
|
BASE_URL=https://artifacto.hubris.network
|
||||||
|
|
||||||
|
# Optional: when set, a reverse proxy forwarding Authentik headers can auto-login
|
||||||
|
# without the admin password. The proxy must inject `X-Artifacto-Gateway: <this
|
||||||
|
# value>` on every request it proxies; Artifacto rejects SSO headers from
|
||||||
|
# requests missing that header so peers that can reach the container directly
|
||||||
|
# can't spoof Authentik identities.
|
||||||
|
# SSO_GATEWAY_SECRET=generate-with-openssl-rand-hex-32
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Then put a reverse proxy (Caddy, nginx, Traefik) in front for HTTPS.
|
|||||||
| `BIND_ADDR` | `:3000` | Listen address |
|
| `BIND_ADDR` | `:3000` | Listen address |
|
||||||
| `MAX_UPLOAD_MB` | `5` | Per-artifact upload cap |
|
| `MAX_UPLOAD_MB` | `5` | Per-artifact upload cap |
|
||||||
| `LOG_LEVEL` | `info` | `info` or `debug` |
|
| `LOG_LEVEL` | `info` | `info` or `debug` |
|
||||||
|
| `SSO_GATEWAY_SECRET` | — | Optional: enables auto-login from a trusted reverse proxy forwarding Authentik headers plus a matching `X-Artifacto-Gateway` header |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ func main() {
|
|||||||
baseURL := envOr("BASE_URL", "http://localhost:3000")
|
baseURL := envOr("BASE_URL", "http://localhost:3000")
|
||||||
adminPw := os.Getenv("ADMIN_PASSWORD")
|
adminPw := os.Getenv("ADMIN_PASSWORD")
|
||||||
sessionSecret := os.Getenv("SESSION_SECRET")
|
sessionSecret := os.Getenv("SESSION_SECRET")
|
||||||
|
ssoSecret := os.Getenv("SSO_GATEWAY_SECRET")
|
||||||
maxMB, _ := strconv.ParseInt(envOr("MAX_UPLOAD_MB", "5"), 10, 64)
|
maxMB, _ := strconv.ParseInt(envOr("MAX_UPLOAD_MB", "5"), 10, 64)
|
||||||
|
|
||||||
if sessionSecret == "" {
|
if sessionSecret == "" {
|
||||||
@@ -46,7 +47,7 @@ func main() {
|
|||||||
defer s.Close()
|
defer s.Close()
|
||||||
|
|
||||||
secure := strings.HasPrefix(baseURL, "https://")
|
secure := strings.HasPrefix(baseURL, "https://")
|
||||||
admin, err := auth.NewAdmin(adminPw, sessionSecret, secure)
|
admin, err := auth.NewAdmin(adminPw, sessionSecret, ssoSecret, secure)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("init admin auth", "err", err)
|
logger.Error("init admin auth", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ services:
|
|||||||
SESSION_SECRET: ${SESSION_SECRET:?set in .env}
|
SESSION_SECRET: ${SESSION_SECRET:?set in .env}
|
||||||
BASE_URL: ${BASE_URL:-https://artifacto.hubris.network}
|
BASE_URL: ${BASE_URL:-https://artifacto.hubris.network}
|
||||||
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-5}
|
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-5}
|
||||||
|
SSO_GATEWAY_SECRET: ${SSO_GATEWAY_SECRET:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
AdminCookie = "artifacto_admin"
|
AdminCookie = "artifacto_admin"
|
||||||
adminLifetime = 30 * 24 * time.Hour
|
adminLifetime = 30 * 24 * time.Hour
|
||||||
|
ssoGatewayHeader = "X-Artifacto-Gateway"
|
||||||
|
ssoUsernameHeader = "X-Authentik-Username"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ctxKey int
|
type ctxKey int
|
||||||
@@ -26,10 +28,11 @@ const ctxAdmin ctxKey = 1
|
|||||||
type Admin struct {
|
type Admin struct {
|
||||||
passwordHash []byte
|
passwordHash []byte
|
||||||
secret []byte
|
secret []byte
|
||||||
|
ssoSecret []byte
|
||||||
secure bool
|
secure bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAdmin(password, secretHex string, secure bool) (*Admin, error) {
|
func NewAdmin(password, secretHex, ssoSecret string, secure bool) (*Admin, error) {
|
||||||
if password == "" {
|
if password == "" {
|
||||||
return nil, errors.New("ADMIN_PASSWORD required")
|
return nil, errors.New("ADMIN_PASSWORD required")
|
||||||
}
|
}
|
||||||
@@ -40,7 +43,11 @@ func NewAdmin(password, secretHex string, secure bool) (*Admin, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
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
|
// HasValidSSO reports whether the request carries an Authentik-forwarded identity
|
||||||
// redirects to /login (for HTML nav) or returns 401 (for API/HTMX).
|
// 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 {
|
func (a *Admin) Middleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c, err := r.Cookie(AdminCookie)
|
if c, err := r.Cookie(AdminCookie); err == nil && a.Verify(c.Value) {
|
||||||
if err != nil || !a.Verify(c.Value) {
|
ctx := context.WithValue(r.Context(), ctxAdmin, true)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.HasValidSSO(r) {
|
||||||
|
a.SetCookie(w)
|
||||||
|
ctx := context.WithValue(r.Context(), ctxAdmin, true)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
if isAPI(r) {
|
if isAPI(r) {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/login?next="+r.URL.RequestURI(), http.StatusSeeOther)
|
http.Redirect(w, r, "/login?next="+r.URL.RequestURI(), http.StatusSeeOther)
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx := context.WithValue(r.Context(), ctxAdmin, true)
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ func (s *Server) getLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// SSO: trusted gateway has proven identity — mint a session and skip the form.
|
||||||
|
if s.admin.HasValidSSO(r) {
|
||||||
|
s.admin.SetCookie(w)
|
||||||
|
next := r.URL.Query().Get("next")
|
||||||
|
if next == "" {
|
||||||
|
next = "/"
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
s.render(w, "login", loginData{Next: r.URL.Query().Get("next")})
|
s.render(w, "login", loginData{Next: r.URL.Query().Get("next")})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user