init push
This commit is contained in:
699
internal/auth/handler.go
Normal file
699
internal/auth/handler.go
Normal file
@@ -0,0 +1,699 @@
|
||||
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")
|
||||
}
|
||||
257
internal/auth/handler_logout_test.go
Normal file
257
internal/auth/handler_logout_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
"github.com/crewjam/saml/samlsp"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"wucher/internal/config"
|
||||
)
|
||||
|
||||
func TestHandleBrowserLocalLogout_RedirectAndClearSessionCookie(t *testing.T) {
|
||||
prev := SAMLMiddleware
|
||||
SAMLMiddleware = nil
|
||||
t.Cleanup(func() { SAMLMiddleware = prev })
|
||||
|
||||
h := NewHandler(config.Config{
|
||||
FrontendURL: "https://wfm-dev.mybit.co.id",
|
||||
CookieDomain: ".mybit.co.id",
|
||||
CookieSecure: true,
|
||||
}, nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/auth/microsoft/logout", h.HandleBrowserLocalLogout)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/logout", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("expected 302, got %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Location"); got != "https://wfm-dev.mybit.co.id/signin?loggedOut=true" {
|
||||
t.Fatalf("unexpected redirect: %s", got)
|
||||
}
|
||||
|
||||
setCookie := strings.Join(resp.Header.Values("Set-Cookie"), "\n")
|
||||
if !strings.Contains(setCookie, "session_token=") {
|
||||
t.Fatalf("expected session_token clear cookie, got %q", setCookie)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(setCookie), "domain=.mybit.co.id") {
|
||||
t.Fatalf("expected cookie domain .mybit.co.id, got %q", setCookie)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRedirectSignaturePayload_PreservesRawEncoding(t *testing.T) {
|
||||
raw := "SAMLResponse=abc%2B123%3D%3D&RelayState=x%2By%252Fz&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256&Signature=ignored"
|
||||
got, err := buildRedirectSignaturePayload(raw, "SAMLResponse")
|
||||
if err != nil {
|
||||
t.Fatalf("build payload: %v", err)
|
||||
}
|
||||
want := "SAMLResponse=abc%2B123%3D%3D&RelayState=x%2By%252Fz&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256"
|
||||
if string(got) != want {
|
||||
t.Fatalf("payload mismatch\nwant: %s\ngot: %s", want, string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSAMLLogoutResponse_GetRedirectSignature_Valid_WithRelayState(t *testing.T) {
|
||||
runValidSAMLResponseRedirectTest(t, "relay-state-1", true)
|
||||
}
|
||||
|
||||
func TestHandleSAMLLogoutResponse_GetRedirectSignature_Valid_WithoutRelayState(t *testing.T) {
|
||||
runValidSAMLResponseRedirectTest(t, "", false)
|
||||
}
|
||||
|
||||
func TestHandleSAMLLogoutResponse_GetRedirectSignature_Invalid(t *testing.T) {
|
||||
priv, cert := testSigningCert(t)
|
||||
setTestSAMLMiddlewareWithCert(t, cert, "https://sts.windows.net/tenant/")
|
||||
|
||||
xmlB64 := base64.StdEncoding.EncodeToString([]byte(testLogoutResponseXML("https://sts.windows.net/tenant/")))
|
||||
sigAlg := "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
|
||||
params := []string{
|
||||
"SAMLResponse=" + url.QueryEscape(xmlB64),
|
||||
"SigAlg=" + url.QueryEscape(sigAlg),
|
||||
}
|
||||
canonical := strings.Join(params, "&")
|
||||
sig := signRedirectPayloadRSA256(t, priv, []byte(canonical))
|
||||
// Break signature on purpose
|
||||
sig = strings.TrimRight(sig, "=") + "A"
|
||||
|
||||
path := "/api/v1/auth/microsoft/logout?" + canonical + "&Signature=" + url.QueryEscape(sig)
|
||||
h := NewHandler(config.Config{FrontendURL: "https://wfm-dev.mybit.co.id"}, nil)
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/auth/microsoft/logout", h.HandleSAMLLogoutResponse)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSAMLLogoutResponse_GetMissingSignature(t *testing.T) {
|
||||
_, cert := testSigningCert(t)
|
||||
setTestSAMLMiddlewareWithCert(t, cert, "https://sts.windows.net/tenant/")
|
||||
|
||||
xmlB64 := base64.StdEncoding.EncodeToString([]byte(testLogoutResponseXML("https://sts.windows.net/tenant/")))
|
||||
path := "/api/v1/auth/microsoft/logout?SAMLResponse=" + url.QueryEscape(xmlB64)
|
||||
|
||||
h := NewHandler(config.Config{FrontendURL: "https://wfm-dev.mybit.co.id"}, nil)
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/auth/microsoft/logout", h.HandleSAMLLogoutResponse)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func runValidSAMLResponseRedirectTest(t *testing.T, relayState string, withRelay bool) {
|
||||
t.Helper()
|
||||
priv, cert := testSigningCert(t)
|
||||
setTestSAMLMiddlewareWithCert(t, cert, "https://sts.windows.net/tenant/")
|
||||
|
||||
xmlB64 := base64.StdEncoding.EncodeToString([]byte(testLogoutResponseXML("https://sts.windows.net/tenant/")))
|
||||
sigAlg := "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
|
||||
|
||||
parts := []string{"SAMLResponse=" + url.QueryEscape(xmlB64)}
|
||||
if withRelay {
|
||||
parts = append(parts, "RelayState="+url.QueryEscape(relayState))
|
||||
}
|
||||
parts = append(parts, "SigAlg="+url.QueryEscape(sigAlg))
|
||||
canonical := strings.Join(parts, "&")
|
||||
sig := signRedirectPayloadRSA256(t, priv, []byte(canonical))
|
||||
rawQuery := canonical + "&Signature=" + url.QueryEscape(sig)
|
||||
|
||||
h := NewHandler(config.Config{FrontendURL: "https://wfm-dev.mybit.co.id"}, nil)
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/auth/microsoft/logout", h.HandleSAMLLogoutResponse)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/logout?"+rawQuery, nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("expected 302, got %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Location"); got != "https://wfm-dev.mybit.co.id/signin?loggedOut=true" {
|
||||
t.Fatalf("unexpected redirect: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func signRedirectPayloadRSA256(t *testing.T, priv *rsa.PrivateKey, payload []byte) string {
|
||||
t.Helper()
|
||||
sum := sha256.Sum256(payload)
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, sum[:])
|
||||
if err != nil {
|
||||
t.Fatalf("sign payload: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
func testSigningCert(t *testing.T) (*rsa.PrivateKey, *x509.Certificate) {
|
||||
t.Helper()
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("gen key: %v", err)
|
||||
}
|
||||
serial, _ := rand.Int(rand.Reader, big.NewInt(1<<62))
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "test-idp"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatalf("parse cert: %v", err)
|
||||
}
|
||||
return priv, cert
|
||||
}
|
||||
|
||||
func setTestSAMLMiddlewareWithCert(t *testing.T, cert *x509.Certificate, entityID string) {
|
||||
t.Helper()
|
||||
prev := SAMLMiddleware
|
||||
SAMLMiddleware = &samlsp.Middleware{}
|
||||
SAMLMiddleware.ServiceProvider.IDPMetadata = &saml.EntityDescriptor{
|
||||
EntityID: entityID,
|
||||
IDPSSODescriptors: []saml.IDPSSODescriptor{
|
||||
{
|
||||
SSODescriptor: saml.SSODescriptor{
|
||||
RoleDescriptor: saml.RoleDescriptor{
|
||||
KeyDescriptors: []saml.KeyDescriptor{
|
||||
{
|
||||
KeyInfo: dsigKeyInfoFromCert(cert),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() { SAMLMiddleware = prev })
|
||||
}
|
||||
|
||||
func dsigKeyInfoFromCert(cert *x509.Certificate) saml.KeyInfo {
|
||||
if cert == nil {
|
||||
return saml.KeyInfo{}
|
||||
}
|
||||
return saml.KeyInfo{
|
||||
X509Data: saml.X509Data{
|
||||
X509Certificates: []saml.X509Certificate{
|
||||
{Data: base64.StdEncoding.EncodeToString(cert.Raw)},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testLogoutResponseXML(issuer string) string {
|
||||
return fmt.Sprintf(
|
||||
`<LogoutResponse xmlns="urn:oasis:names:tc:SAML:2.0:protocol" ID="_id123" Version="2.0" IssueInstant="2026-05-28T00:00:00Z"><Issuer xmlns="urn:oasis:names:tc:SAML:2.0:assertion">%s</Issuer><Status><StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></Status></LogoutResponse>`,
|
||||
issuer,
|
||||
)
|
||||
}
|
||||
|
||||
func TestCertificateThumbprintSHA1(t *testing.T) {
|
||||
_, cert := testSigningCert(t)
|
||||
got := certificateThumbprintSHA1(cert)
|
||||
if got == "" {
|
||||
t.Fatal("thumbprint should not be empty")
|
||||
}
|
||||
sum := sha1.Sum(cert.Raw)
|
||||
want := strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
if got != want {
|
||||
t.Fatalf("thumbprint mismatch: got=%s want=%s", got, want)
|
||||
}
|
||||
}
|
||||
124
internal/auth/saml.go
Normal file
124
internal/auth/saml.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
"github.com/crewjam/saml/samlsp"
|
||||
"wucher/internal/config"
|
||||
)
|
||||
|
||||
var SAMLMiddleware *samlsp.Middleware
|
||||
|
||||
func InitSAML(cfg config.Config) error {
|
||||
keyPair, err := tls.LoadX509KeyPair(cfg.SAMLCertFile, cfg.SAMLKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load saml keypair: %w", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(keyPair.Certificate[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse saml certificate: %w", err)
|
||||
}
|
||||
signer, ok := keyPair.PrivateKey.(crypto.Signer)
|
||||
if !ok {
|
||||
return fmt.Errorf("saml private key is not a crypto.Signer")
|
||||
}
|
||||
|
||||
idpMetadataBytes, err := os.ReadFile(cfg.SAMLMetadataFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read idp metadata: %w", err)
|
||||
}
|
||||
|
||||
var idpMetadata saml.EntityDescriptor
|
||||
if err := xml.Unmarshal(idpMetadataBytes, &idpMetadata); err != nil {
|
||||
return fmt.Errorf("parse idp metadata xml: %w", err)
|
||||
}
|
||||
|
||||
rootURL, err := url.Parse(cfg.SAMLRootURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid SAML_ROOT_URL: %w", err)
|
||||
}
|
||||
|
||||
m, err := samlsp.New(samlsp.Options{
|
||||
EntityID: cfg.SAMLRootURL,
|
||||
URL: *rootURL,
|
||||
Key: signer,
|
||||
Certificate: cert,
|
||||
IDPMetadata: &idpMetadata,
|
||||
AllowIDPInitiated: true,
|
||||
CookieSameSite: http.SameSiteLaxMode,
|
||||
DefaultRedirectURI: cfg.FrontendURL + "/dashboard",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("init saml middleware: %w", err)
|
||||
}
|
||||
|
||||
m.ServiceProvider.MetadataURL.Path = "/api/v1/auth/saml/metadata"
|
||||
m.ServiceProvider.AcsURL.Path = "/api/v1/auth/microsoft/callback"
|
||||
m.ServiceProvider.SloURL.Path = "/api/v1/auth/microsoft/logout"
|
||||
m.ServiceProvider.ValidateAudienceRestriction = func(assertion *saml.Assertion) error {
|
||||
if assertion == nil || assertion.Conditions == nil || len(assertion.Conditions.AudienceRestrictions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
expected := strings.TrimRight(firstNonEmpty(m.ServiceProvider.EntityID, m.ServiceProvider.MetadataURL.String()), "/")
|
||||
for _, audienceRestriction := range assertion.Conditions.AudienceRestrictions {
|
||||
if strings.TrimRight(audienceRestriction.Audience.Value, "/") == expected {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("assertion Conditions AudienceRestriction does not contain %q", expected)
|
||||
}
|
||||
SAMLMiddleware = m
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getIDPCertificates() ([]*x509.Certificate, error) {
|
||||
if SAMLMiddleware == nil || SAMLMiddleware.ServiceProvider.IDPMetadata == nil {
|
||||
return nil, fmt.Errorf("saml middleware not initialized")
|
||||
}
|
||||
|
||||
var certificates []*x509.Certificate
|
||||
for _, idp := range SAMLMiddleware.ServiceProvider.IDPMetadata.IDPSSODescriptors {
|
||||
for _, kd := range idp.KeyDescriptors {
|
||||
for _, certData := range kd.KeyInfo.X509Data.X509Certificates {
|
||||
raw := strings.TrimSpace(certData.Data)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
der, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
certificates = append(certificates, cert)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(certificates) == 0 {
|
||||
return nil, fmt.Errorf("no idp certificate in metadata")
|
||||
}
|
||||
return certificates, nil
|
||||
}
|
||||
127
internal/auth/session.go
Normal file
127
internal/auth/session.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserSession struct {
|
||||
UserID string `json:"user_id"`
|
||||
MicrosoftNameID string `json:"microsoft_name_id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
LoggedInAt time.Time `json:"logged_in_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type SessionRecord struct {
|
||||
Token string `gorm:"type:varchar(128);primaryKey;column:token"`
|
||||
UserID string `gorm:"type:varchar(191);index;not null;column:user_id"`
|
||||
MicrosoftNameID string `gorm:"type:varchar(255);index;not null;column:microsoft_name_id"`
|
||||
Email string `gorm:"type:varchar(191);index;not null;column:email"`
|
||||
Name string `gorm:"type:varchar(191);not null;column:name"`
|
||||
LoggedInAt time.Time `gorm:"not null;column:logged_in_at"`
|
||||
ExpiresAt time.Time `gorm:"index;not null;column:expires_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (SessionRecord) TableName() string { return "saml_sessions" }
|
||||
|
||||
var sessionDB *gorm.DB
|
||||
|
||||
func InitSessionStoreDB(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("database is required")
|
||||
}
|
||||
if err := db.AutoMigrate(&SessionRecord{}); err != nil {
|
||||
return fmt.Errorf("auto migrate saml_sessions: %w", err)
|
||||
}
|
||||
sessionDB = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateSession(session UserSession) (string, error) {
|
||||
if sessionDB == nil {
|
||||
return "", fmt.Errorf("session store is not initialized")
|
||||
}
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if session.LoggedInAt.IsZero() {
|
||||
session.LoggedInAt = time.Now().UTC()
|
||||
}
|
||||
if session.ExpiresAt.IsZero() {
|
||||
session.ExpiresAt = session.LoggedInAt.Add(8 * time.Hour)
|
||||
}
|
||||
|
||||
record := SessionRecord{
|
||||
Token: token,
|
||||
UserID: session.UserID,
|
||||
MicrosoftNameID: session.MicrosoftNameID,
|
||||
Email: session.Email,
|
||||
Name: session.Name,
|
||||
LoggedInAt: session.LoggedInAt,
|
||||
ExpiresAt: session.ExpiresAt,
|
||||
}
|
||||
if err := sessionDB.Create(&record).Error; err != nil {
|
||||
return "", fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func GetSession(token string) (*UserSession, error) {
|
||||
if sessionDB == nil {
|
||||
return nil, fmt.Errorf("session store is not initialized")
|
||||
}
|
||||
var record SessionRecord
|
||||
err := sessionDB.Where("token = ?", token).First(&record).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if !record.ExpiresAt.After(now) {
|
||||
_ = sessionDB.Delete(&SessionRecord{}, "token = ?", token).Error
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
return &UserSession{
|
||||
UserID: record.UserID,
|
||||
MicrosoftNameID: record.MicrosoftNameID,
|
||||
Email: record.Email,
|
||||
Name: record.Name,
|
||||
LoggedInAt: record.LoggedInAt,
|
||||
ExpiresAt: record.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func DeleteSession(token string) error {
|
||||
if sessionDB == nil {
|
||||
return fmt.Errorf("session store is not initialized")
|
||||
}
|
||||
return sessionDB.Delete(&SessionRecord{}, "token = ?", token).Error
|
||||
}
|
||||
|
||||
func DeleteSessionByNameID(nameID string) error {
|
||||
if sessionDB == nil {
|
||||
return fmt.Errorf("session store is not initialized")
|
||||
}
|
||||
return sessionDB.Delete(&SessionRecord{}, "microsoft_name_id = ?", nameID).Error
|
||||
}
|
||||
|
||||
func generateToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generate token: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
if token == "" {
|
||||
return "", errors.New("empty token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
Reference in New Issue
Block a user