Files
fm_be/internal/auth/handler.go
2026-07-16 22:16:45 +07:00

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")
}