700 lines
21 KiB
Go
700 lines
21 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/flate"
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/rsa"
|
|
"crypto/sha1"
|
|
"crypto/sha256"
|
|
"crypto/sha512"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/beevik/etree"
|
|
"github.com/crewjam/saml"
|
|
"github.com/gofiber/fiber/v2"
|
|
dsig "github.com/russellhaering/goxmldsig"
|
|
"github.com/valyala/fasthttp/fasthttpadaptor"
|
|
"wucher/internal/config"
|
|
"wucher/internal/service"
|
|
)
|
|
|
|
type Handler struct {
|
|
cfg config.Config
|
|
auth *service.AuthService
|
|
}
|
|
|
|
func NewHandler(cfg config.Config, authSvc *service.AuthService) *Handler {
|
|
return &Handler{cfg: cfg, auth: authSvc}
|
|
}
|
|
|
|
func (h *Handler) HandleSAMLCallback(c *fiber.Ctx) error {
|
|
if SAMLMiddleware == nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "saml is not initialized"})
|
|
}
|
|
req, err := toHTTPRequest(c)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
|
|
}
|
|
if err := req.ParseForm(); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid form payload"})
|
|
}
|
|
assertion, err := SAMLMiddleware.ServiceProvider.ParseResponse(req, []string{""})
|
|
if err != nil {
|
|
resp := fiber.Map{"error": "invalid saml response"}
|
|
if strings.EqualFold(h.cfg.AppEnv, "development") || strings.EqualFold(h.cfg.AppEnv, "local") {
|
|
resp["detail"] = err.Error()
|
|
var invalidRespErr *saml.InvalidResponseError
|
|
if errors.As(err, &invalidRespErr) && invalidRespErr.PrivateErr != nil {
|
|
resp["private_detail"] = invalidRespErr.PrivateErr.Error()
|
|
}
|
|
if raw := c.FormValue("SAMLResponse"); raw != "" {
|
|
if statusCode, statusMessage := samlResponseStatus(raw); statusCode != "" || statusMessage != "" {
|
|
resp["status_code"] = statusCode
|
|
resp["status_message"] = statusMessage
|
|
}
|
|
}
|
|
}
|
|
return c.Status(fiber.StatusUnauthorized).JSON(resp)
|
|
}
|
|
|
|
nameID := ""
|
|
email := ""
|
|
givenName := ""
|
|
surname := ""
|
|
displayName := ""
|
|
if assertion.Subject != nil {
|
|
nameID = assertion.Subject.NameID.Value
|
|
}
|
|
for _, stmt := range assertion.AttributeStatements {
|
|
for _, attr := range stmt.Attributes {
|
|
if len(attr.Values) == 0 {
|
|
continue
|
|
}
|
|
val := attr.Values[0].Value
|
|
switch attr.Name {
|
|
case "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "emailaddress":
|
|
email = val
|
|
case "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", "givenname":
|
|
givenName = val
|
|
case "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname", "surname":
|
|
surname = val
|
|
case "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "name":
|
|
displayName = val
|
|
}
|
|
}
|
|
}
|
|
if displayName == "" {
|
|
displayName = givenName + " " + surname
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
session := UserSession{
|
|
UserID: nameID,
|
|
MicrosoftNameID: nameID,
|
|
Email: email,
|
|
Name: displayName,
|
|
LoggedInAt: now,
|
|
ExpiresAt: now.Add(time.Duration(h.cfg.SessionTTLHour) * time.Hour),
|
|
}
|
|
token, err := CreateSession(session)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "create session failed"})
|
|
}
|
|
|
|
if h.auth != nil {
|
|
claims := map[string]any{
|
|
"sub": nameID,
|
|
"email": email,
|
|
"name": displayName,
|
|
"oid": nameID,
|
|
"exp": time.Now().UTC().Add(5 * time.Minute).Unix(),
|
|
}
|
|
identity, err := h.auth.ExchangeMicrosoftToken(c.UserContext(), "saml_access", "saml_id", claims)
|
|
if err == nil && identity != nil {
|
|
user, userErr := h.auth.GetUserByID(c.UserContext(), identity.UserID)
|
|
if userErr == nil && user != nil {
|
|
tokens, tokenErr := h.auth.IssueTokensWithClientForProviderAndDeviceType(
|
|
c.UserContext(),
|
|
user,
|
|
c.Get(fiber.HeaderUserAgent),
|
|
c.IP(),
|
|
"microsoft",
|
|
readDeviceTypeHint(c),
|
|
)
|
|
if tokenErr == nil {
|
|
setAuthCookies(c, h.auth, tokens)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: "session_token",
|
|
Value: token,
|
|
HTTPOnly: true,
|
|
Secure: h.cfg.CookieSecure,
|
|
SameSite: "lax",
|
|
Path: "/",
|
|
Domain: h.cfg.CookieDomain,
|
|
Expires: session.ExpiresAt,
|
|
})
|
|
return c.Redirect(h.cfg.FrontendURL+"/dashboard", fiber.StatusFound)
|
|
}
|
|
|
|
func (h *Handler) HandleSLO(c *fiber.Ctx) error {
|
|
return h.HandleSAMLLogoutRequest(c)
|
|
}
|
|
|
|
func (h *Handler) HandleSAMLLogoutRequest(c *fiber.Ctx) error {
|
|
if SAMLMiddleware == nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "saml is not initialized"})
|
|
}
|
|
log.Printf("saml logout request: has_saml_request=%t has_saml_response=%t method=%s", formOrQuery(c, "SAMLRequest") != "", formOrQuery(c, "SAMLResponse") != "", c.Method())
|
|
rawReq := formOrQuery(c, "SAMLRequest")
|
|
if rawReq == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing SAMLRequest"})
|
|
}
|
|
|
|
xmlBytes, err := decodeSAMLMessage(rawReq)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid SAMLRequest encoding"})
|
|
}
|
|
if c.Method() == fiber.MethodGet {
|
|
if err := validateRedirectSAMLSignature(c); err != nil {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLRequest signature"})
|
|
}
|
|
} else {
|
|
if err := validateLogoutRequestSignature(xmlBytes); err != nil {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLRequest signature"})
|
|
}
|
|
}
|
|
|
|
var logoutReq saml.LogoutRequest
|
|
if err := xml.Unmarshal(xmlBytes, &logoutReq); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid LogoutRequest XML"})
|
|
}
|
|
if err := validateSAMLMessageIssuer(logoutReq.Issuer.Value); err != nil {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLRequest issuer"})
|
|
}
|
|
nameID := strings.TrimSpace(logoutReq.NameID.Value)
|
|
h.cleanupUserAuthSession(c, nameID)
|
|
log.Printf("saml logout request processed: name_id_present=%t", nameID != "")
|
|
|
|
relayState := formOrQuery(c, "RelayState")
|
|
redirectURL, err := SAMLMiddleware.ServiceProvider.MakeRedirectLogoutResponse(logoutReq.ID, relayState)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to build LogoutResponse"})
|
|
}
|
|
return c.Redirect(redirectURL.String(), fiber.StatusFound)
|
|
}
|
|
|
|
func (h *Handler) HandleSAMLLogoutResponse(c *fiber.Ctx) error {
|
|
if SAMLMiddleware == nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "saml is not initialized"})
|
|
}
|
|
rawQuery := c.Context().QueryArgs().String()
|
|
hasSAMLResponse := formOrQuery(c, "SAMLResponse") != ""
|
|
hasRelayState := formOrQuery(c, "RelayState") != ""
|
|
hasSigAlg := formOrQuery(c, "SigAlg") != ""
|
|
hasSignature := formOrQuery(c, "Signature") != ""
|
|
sigAlg := formOrQuery(c, "SigAlg")
|
|
log.Printf("saml logout response: method=%s path=%s hasSAMLResponse=%t hasRelayState=%t hasSigAlg=%t hasSignature=%t sigAlg=%q rawQueryLen=%d",
|
|
c.Method(), c.Path(), hasSAMLResponse, hasRelayState, hasSigAlg, hasSignature, sigAlg, len(rawQuery))
|
|
rawResp := formOrQuery(c, "SAMLResponse")
|
|
if rawResp == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing SAMLResponse"})
|
|
}
|
|
|
|
if c.Method() == fiber.MethodGet && hasSigAlg && hasSignature {
|
|
if err := validateRedirectSAMLSignatureRaw(rawQuery, "SAMLResponse"); err != nil {
|
|
log.Printf("saml logout response: verification_result=failed mode=redirect reason=%v", err)
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse signature"})
|
|
}
|
|
log.Printf("saml logout response: verification_result=ok mode=redirect")
|
|
}
|
|
|
|
xmlBytes, err := decodeSAMLMessage(rawResp)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid SAMLResponse encoding"})
|
|
}
|
|
|
|
if c.Method() == fiber.MethodGet && !(hasSigAlg && hasSignature) {
|
|
if hasSigAlg != hasSignature {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing SAMLResponse signature"})
|
|
}
|
|
if !hasXMLSignature(xmlBytes) {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing SAMLResponse signature"})
|
|
}
|
|
if err := validateLogoutResponseSignature(xmlBytes); err != nil {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse signature"})
|
|
}
|
|
} else if c.Method() != fiber.MethodGet {
|
|
if err := validateLogoutResponseSignature(xmlBytes); err != nil {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse signature"})
|
|
}
|
|
}
|
|
|
|
var logoutResp saml.LogoutResponse
|
|
if err := xml.Unmarshal(xmlBytes, &logoutResp); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid LogoutResponse XML"})
|
|
}
|
|
if err := validateSAMLMessageIssuer(logoutResp.Issuer.Value); err != nil {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse issuer"})
|
|
}
|
|
if strings.TrimSpace(logoutResp.Status.StatusCode.Value) != saml.StatusSuccess {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse status"})
|
|
}
|
|
h.cleanupUserAuthSession(c, "")
|
|
return c.Redirect(h.logoutRedirectURL(), fiber.StatusFound)
|
|
}
|
|
|
|
func (h *Handler) HandleMetadata(c *fiber.Ctx) error {
|
|
if SAMLMiddleware == nil {
|
|
return c.Status(fiber.StatusInternalServerError).SendString("saml is not initialized")
|
|
}
|
|
buf, err := xml.MarshalIndent(SAMLMiddleware.ServiceProvider.Metadata(), "", " ")
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).SendString("failed to marshal metadata")
|
|
}
|
|
c.Set(fiber.HeaderContentType, "application/samlmetadata+xml")
|
|
return c.Send(buf)
|
|
}
|
|
|
|
func (h *Handler) HandleLogin(c *fiber.Ctx) error {
|
|
if SAMLMiddleware == nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "saml is not initialized"})
|
|
}
|
|
redirectURL, err := SAMLMiddleware.ServiceProvider.MakeRedirectAuthenticationRequest("")
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to build authn request"})
|
|
}
|
|
if wantsJSON(c) {
|
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
|
"data": fiber.Map{
|
|
"type": "auth_microsoft_url",
|
|
"attributes": fiber.Map{
|
|
"url": redirectURL.String(),
|
|
"redirect_url": redirectURL.String(),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
return c.Redirect(redirectURL.String(), fiber.StatusFound)
|
|
}
|
|
|
|
func (h *Handler) HandleLogout(c *fiber.Ctx) error {
|
|
return h.HandleBrowserLocalLogout(c)
|
|
}
|
|
|
|
func (h *Handler) HandleBrowserLocalLogout(c *fiber.Ctx) error {
|
|
log.Printf("browser local logout: method=%s", c.Method())
|
|
var nameID string
|
|
if token := strings.TrimSpace(c.Cookies("session_token")); token != "" {
|
|
if sess, err := GetSession(token); err == nil {
|
|
nameID = strings.TrimSpace(sess.MicrosoftNameID)
|
|
}
|
|
}
|
|
h.cleanupUserAuthSession(c, nameID)
|
|
if nameID != "" && SAMLMiddleware != nil {
|
|
redirectURL, err := SAMLMiddleware.ServiceProvider.MakeRedirectLogoutRequest(nameID, "")
|
|
if err == nil {
|
|
return c.Redirect(redirectURL.String(), fiber.StatusFound)
|
|
}
|
|
}
|
|
return c.Redirect(h.logoutRedirectURL(), fiber.StatusFound)
|
|
}
|
|
|
|
func toHTTPRequest(c *fiber.Ctx) (*http.Request, error) {
|
|
r := new(http.Request)
|
|
if err := fasthttpadaptor.ConvertRequest(c.Context(), r, true); err != nil {
|
|
return nil, err
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func validateLogoutRequestSignature(xmlBytes []byte) error {
|
|
certs, err := getIDPCertificates()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
certStore := dsig.MemoryX509CertificateStore{Roots: certs}
|
|
validator := dsig.NewDefaultValidationContext(&certStore)
|
|
|
|
doc := etree.NewDocument()
|
|
if err := doc.ReadFromBytes(xmlBytes); err != nil {
|
|
return fmt.Errorf("parse xml: %w", err)
|
|
}
|
|
root := doc.Root()
|
|
if root == nil {
|
|
return fmt.Errorf("empty xml")
|
|
}
|
|
_, err = validator.Validate(root)
|
|
return err
|
|
}
|
|
|
|
func validateRedirectSAMLSignature(c *fiber.Ctx) error {
|
|
rawQuery := c.Context().QueryArgs().String()
|
|
return validateRedirectSAMLSignatureRaw(rawQuery, "")
|
|
}
|
|
|
|
func validateRedirectSAMLSignatureRaw(rawQuery, expectedMessageKey string) error {
|
|
params, err := url.ParseQuery(rawQuery)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid query: %w", err)
|
|
}
|
|
|
|
sigAlg := params.Get("SigAlg")
|
|
signatureB64 := params.Get("Signature")
|
|
if sigAlg == "" || signatureB64 == "" {
|
|
return fmt.Errorf("missing SigAlg or Signature")
|
|
}
|
|
|
|
signedPayload, err := buildRedirectSignaturePayload(rawQuery, expectedMessageKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.Printf("saml redirect signature: canonicalLen=%d", len(signedPayload))
|
|
|
|
signature, err := base64.StdEncoding.DecodeString(signatureB64)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid signature encoding: %w", err)
|
|
}
|
|
|
|
certs, err := getIDPCertificates()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, cert := range certs {
|
|
log.Printf("saml redirect signature: certThumbprint=%s", certificateThumbprintSHA1(cert))
|
|
if err := verifyRedirectSignature(cert.PublicKey, sigAlg, signedPayload, signature); err == nil {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("signature verification failed")
|
|
}
|
|
|
|
func buildRedirectSignaturePayload(rawQuery, expectedMessageKey string) ([]byte, error) {
|
|
var samlReq, samlResp, relay, sigAlg string
|
|
for _, part := range strings.Split(rawQuery, "&") {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
key, val, found := strings.Cut(part, "=")
|
|
if !found {
|
|
continue
|
|
}
|
|
switch key {
|
|
case "SAMLRequest":
|
|
if samlReq == "" {
|
|
samlReq = val
|
|
}
|
|
case "SAMLResponse":
|
|
if samlResp == "" {
|
|
samlResp = val
|
|
}
|
|
case "RelayState":
|
|
if relay == "" {
|
|
relay = val
|
|
}
|
|
case "SigAlg":
|
|
if sigAlg == "" {
|
|
sigAlg = val
|
|
}
|
|
}
|
|
}
|
|
|
|
msgKey := "SAMLRequest"
|
|
msgVal := samlReq
|
|
if msgVal == "" {
|
|
msgKey = "SAMLResponse"
|
|
msgVal = samlResp
|
|
}
|
|
if expectedMessageKey != "" && msgKey != expectedMessageKey {
|
|
return nil, fmt.Errorf("missing %s", expectedMessageKey)
|
|
}
|
|
if msgVal == "" || sigAlg == "" {
|
|
return nil, fmt.Errorf("missing SAML payload or SigAlg")
|
|
}
|
|
|
|
payload := msgKey + "=" + msgVal
|
|
if relay != "" {
|
|
payload += "&RelayState=" + relay
|
|
}
|
|
payload += "&SigAlg=" + sigAlg
|
|
return []byte(payload), nil
|
|
}
|
|
|
|
func validateLogoutResponseSignature(xmlBytes []byte) error {
|
|
return validateLogoutRequestSignature(xmlBytes)
|
|
}
|
|
|
|
func hasXMLSignature(xmlBytes []byte) bool {
|
|
doc := etree.NewDocument()
|
|
if err := doc.ReadFromBytes(xmlBytes); err != nil {
|
|
return false
|
|
}
|
|
root := doc.Root()
|
|
if root == nil {
|
|
return false
|
|
}
|
|
for _, el := range root.FindElements(".//*") {
|
|
if strings.EqualFold(el.Tag, "Signature") || strings.HasSuffix(el.Tag, ":Signature") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func certificateThumbprintSHA1(cert *x509.Certificate) string {
|
|
if cert == nil {
|
|
return ""
|
|
}
|
|
sum := sha1.Sum(cert.Raw)
|
|
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
|
}
|
|
|
|
func validateSAMLMessageIssuer(issuer string) error {
|
|
issuer = strings.TrimSpace(issuer)
|
|
if issuer == "" {
|
|
return fmt.Errorf("missing issuer")
|
|
}
|
|
if SAMLMiddleware == nil || SAMLMiddleware.ServiceProvider.IDPMetadata == nil {
|
|
return fmt.Errorf("missing idp metadata")
|
|
}
|
|
expected := strings.TrimSpace(SAMLMiddleware.ServiceProvider.IDPMetadata.EntityID)
|
|
if expected == "" {
|
|
return fmt.Errorf("missing expected issuer")
|
|
}
|
|
if issuer != expected {
|
|
return fmt.Errorf("unexpected issuer")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *Handler) cleanupUserAuthSession(c *fiber.Ctx, nameID string) {
|
|
if strings.TrimSpace(nameID) != "" {
|
|
_ = DeleteSessionByNameID(nameID)
|
|
}
|
|
if token := strings.TrimSpace(c.Cookies("session_token")); token != "" {
|
|
_ = DeleteSession(token)
|
|
}
|
|
clearSessionCookie(c, h.cfg.CookieDomain, h.cfg.CookieSecure)
|
|
if h.auth == nil {
|
|
return
|
|
}
|
|
accessToken := strings.TrimSpace(c.Cookies(h.auth.AccessCookieName()))
|
|
refreshToken := strings.TrimSpace(c.Cookies(h.auth.RefreshCookieName()))
|
|
if accessToken != "" {
|
|
if userID, err := h.auth.ParseAccessToken(c.UserContext(), accessToken); err == nil && len(userID) == 16 {
|
|
_ = h.auth.RevokeAllUserSessions(c.UserContext(), userID)
|
|
log.Printf("logout cleanup: revoked app sessions user_id=%x", userID)
|
|
}
|
|
}
|
|
if refreshToken != "" {
|
|
_ = h.auth.RevokeRefreshToken(c.UserContext(), refreshToken)
|
|
}
|
|
clearAuthCookies(c, h.auth)
|
|
}
|
|
|
|
func clearSessionCookie(c *fiber.Ctx, domain string, secure bool) {
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: "session_token",
|
|
Value: "",
|
|
Path: "/",
|
|
Domain: domain,
|
|
HTTPOnly: true,
|
|
Secure: secure,
|
|
SameSite: "lax",
|
|
MaxAge: -1,
|
|
Expires: time.Unix(0, 0),
|
|
})
|
|
}
|
|
|
|
func clearAuthCookies(c *fiber.Ctx, svc *service.AuthService) {
|
|
if svc == nil {
|
|
return
|
|
}
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: svc.AccessCookieName(),
|
|
Value: "",
|
|
HTTPOnly: true,
|
|
Secure: svc.CookieSecure(),
|
|
SameSite: parseSameSite(svc.CookieSameSite()),
|
|
Domain: svc.CookieDomain(),
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
})
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: svc.RefreshCookieName(),
|
|
Value: "",
|
|
HTTPOnly: true,
|
|
Secure: svc.CookieSecure(),
|
|
SameSite: parseSameSite(svc.CookieSameSite()),
|
|
Domain: svc.CookieDomain(),
|
|
Path: "/api/v1/auth/refresh",
|
|
MaxAge: -1,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) logoutRedirectURL() string {
|
|
base := strings.TrimRight(strings.TrimSpace(h.cfg.FrontendURL), "/")
|
|
if base == "" {
|
|
return "/signin?loggedOut=true"
|
|
}
|
|
return base + "/signin?loggedOut=true"
|
|
}
|
|
|
|
func verifyRedirectSignature(pub any, sigAlg string, payload, signature []byte) error {
|
|
switch sigAlg {
|
|
case "http://www.w3.org/2000/09/xmldsig#rsa-sha1":
|
|
sum := sha1.Sum(payload)
|
|
key, ok := pub.(*rsa.PublicKey)
|
|
if !ok {
|
|
return fmt.Errorf("unexpected key type %T", pub)
|
|
}
|
|
return rsa.VerifyPKCS1v15(key, crypto.SHA1, sum[:], signature)
|
|
case "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256":
|
|
sum := sha256.Sum256(payload)
|
|
key, ok := pub.(*rsa.PublicKey)
|
|
if !ok {
|
|
return fmt.Errorf("unexpected key type %T", pub)
|
|
}
|
|
return rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], signature)
|
|
case "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384":
|
|
sum := sha512.Sum384(payload)
|
|
key, ok := pub.(*rsa.PublicKey)
|
|
if !ok {
|
|
return fmt.Errorf("unexpected key type %T", pub)
|
|
}
|
|
return rsa.VerifyPKCS1v15(key, crypto.SHA384, sum[:], signature)
|
|
case "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512":
|
|
sum := sha512.Sum512(payload)
|
|
key, ok := pub.(*rsa.PublicKey)
|
|
if !ok {
|
|
return fmt.Errorf("unexpected key type %T", pub)
|
|
}
|
|
return rsa.VerifyPKCS1v15(key, crypto.SHA512, sum[:], signature)
|
|
case "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256":
|
|
sum := sha256.Sum256(payload)
|
|
key, ok := pub.(*ecdsa.PublicKey)
|
|
if !ok {
|
|
return fmt.Errorf("unexpected key type %T", pub)
|
|
}
|
|
if ecdsa.VerifyASN1(key, sum[:], signature) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("ecdsa verify failed")
|
|
default:
|
|
return fmt.Errorf("unsupported SigAlg %q", sigAlg)
|
|
}
|
|
}
|
|
|
|
func formOrQuery(c *fiber.Ctx, key string) string {
|
|
if v := strings.TrimSpace(c.FormValue(key)); v != "" {
|
|
return v
|
|
}
|
|
return strings.TrimSpace(c.Query(key))
|
|
}
|
|
|
|
func decodeSAMLMessage(raw string) ([]byte, error) {
|
|
decoded, err := base64.StdEncoding.DecodeString(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if bytes.Contains(decoded, []byte("<")) {
|
|
return decoded, nil
|
|
}
|
|
|
|
reader := flate.NewReader(bytes.NewReader(decoded))
|
|
defer reader.Close()
|
|
var out bytes.Buffer
|
|
if _, err := out.ReadFrom(reader); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
func samlResponseStatus(raw string) (string, string) {
|
|
decoded, err := base64.StdEncoding.DecodeString(raw)
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
|
|
var resp saml.Response
|
|
if err := xml.Unmarshal(decoded, &resp); err != nil {
|
|
return "", ""
|
|
}
|
|
|
|
if resp.Status.StatusCode.Value == "" && resp.Status.StatusMessage == nil {
|
|
return "", ""
|
|
}
|
|
|
|
statusMessage := ""
|
|
if resp.Status.StatusMessage != nil {
|
|
statusMessage = strings.TrimSpace(resp.Status.StatusMessage.Value)
|
|
}
|
|
return resp.Status.StatusCode.Value, statusMessage
|
|
}
|
|
|
|
func readDeviceTypeHint(c *fiber.Ctx) string {
|
|
return strings.TrimSpace(c.Get("X-Device-Type"))
|
|
}
|
|
|
|
func setAuthCookies(c *fiber.Ctx, svc *service.AuthService, tokens service.TokenPair) {
|
|
if svc == nil {
|
|
return
|
|
}
|
|
refreshTTL := tokens.RefreshTTL
|
|
if refreshTTL <= 0 {
|
|
refreshTTL = svc.RefreshTTL()
|
|
}
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: svc.AccessCookieName(),
|
|
Value: tokens.AccessToken,
|
|
HTTPOnly: true,
|
|
Secure: svc.CookieSecure(),
|
|
SameSite: parseSameSite(svc.CookieSameSite()),
|
|
Domain: svc.CookieDomain(),
|
|
Path: "/",
|
|
MaxAge: int(svc.AccessTTL().Seconds()),
|
|
})
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: svc.RefreshCookieName(),
|
|
Value: tokens.RefreshToken,
|
|
HTTPOnly: true,
|
|
Secure: svc.CookieSecure(),
|
|
SameSite: parseSameSite(svc.CookieSameSite()),
|
|
Domain: svc.CookieDomain(),
|
|
Path: "/api/v1/auth/refresh",
|
|
MaxAge: int(refreshTTL.Seconds()),
|
|
})
|
|
}
|
|
|
|
func parseSameSite(v string) string {
|
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
|
case "none":
|
|
return "none"
|
|
case "strict":
|
|
return "strict"
|
|
default:
|
|
return "lax"
|
|
}
|
|
}
|
|
|
|
func wantsJSON(c *fiber.Ctx) bool {
|
|
accept := strings.ToLower(strings.TrimSpace(c.Get(fiber.HeaderAccept)))
|
|
return strings.Contains(accept, "application/json") || strings.Contains(accept, "application/vnd.api+json")
|
|
}
|