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.
161 lines
3.9 KiB
Go
161 lines
3.9 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const (
|
|
AdminCookie = "artifacto_admin"
|
|
adminLifetime = 30 * 24 * time.Hour
|
|
ssoGatewayHeader = "X-Artifacto-Gateway"
|
|
ssoUsernameHeader = "X-Authentik-Username"
|
|
)
|
|
|
|
type ctxKey int
|
|
|
|
const ctxAdmin ctxKey = 1
|
|
|
|
type Admin struct {
|
|
passwordHash []byte
|
|
secret []byte
|
|
ssoSecret []byte
|
|
secure bool
|
|
}
|
|
|
|
func NewAdmin(password, secretHex, ssoSecret string, secure bool) (*Admin, error) {
|
|
if password == "" {
|
|
return nil, errors.New("ADMIN_PASSWORD required")
|
|
}
|
|
if len(secretHex) < 32 {
|
|
return nil, errors.New("SESSION_SECRET must be >= 32 chars")
|
|
}
|
|
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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 {
|
|
return bcrypt.CompareHashAndPassword(a.passwordHash, []byte(p)) == nil
|
|
}
|
|
|
|
// Issue returns a signed cookie value "issued_at.sig".
|
|
func (a *Admin) Issue() string {
|
|
issued := strconv.FormatInt(time.Now().Unix(), 10)
|
|
return issued + "." + a.sign("admin|"+issued)
|
|
}
|
|
|
|
func (a *Admin) Verify(raw string) bool {
|
|
parts := strings.SplitN(raw, ".", 2)
|
|
if len(parts) != 2 {
|
|
return false
|
|
}
|
|
issued, err := strconv.ParseInt(parts[0], 10, 64)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if time.Since(time.Unix(issued, 0)) > adminLifetime {
|
|
return false
|
|
}
|
|
expected := a.sign("admin|" + parts[0])
|
|
return hmac.Equal([]byte(expected), []byte(parts[1]))
|
|
}
|
|
|
|
func (a *Admin) sign(s string) string {
|
|
m := hmac.New(sha256.New, a.secret)
|
|
m.Write([]byte(s))
|
|
return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
|
|
}
|
|
|
|
func (a *Admin) SetCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: AdminCookie,
|
|
Value: a.Issue(),
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: a.secure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Expires: time.Now().Add(adminLifetime),
|
|
})
|
|
}
|
|
|
|
func (a *Admin) ClearCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: AdminCookie,
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: a.secure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: -1,
|
|
})
|
|
}
|
|
|
|
// 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) {
|
|
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
|
|
}
|
|
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)
|
|
})
|
|
}
|
|
|
|
func isAPI(r *http.Request) bool {
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
return true
|
|
}
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func IsAdmin(ctx context.Context) bool {
|
|
v, _ := ctx.Value(ctxAdmin).(bool)
|
|
return v
|
|
}
|