init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

699
internal/auth/handler.go Normal file
View 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")
}

View 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
View 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
View 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
}