package middleware import ( "strings" "github.com/gofiber/fiber/v2" "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" "gitlab.com/mbugroup/lti-api.git/internal/utils" ) const ( authContextLocalsKey = "auth.context" authUserLocalsKey = "auth.user" ) // AuthContext keeps authentication details captured by the middleware. type AuthContext struct { Token string Verification *sso.VerificationResult User *entity.User Roles []sso.Role Permissions map[string]struct{} } // Auth validates the incoming request against the central SSO access token and // loads the corresponding local user. Optional scopes can be provided to enforce // fine-grained authorization using the SSO access token scopes. func Auth(userService service.UserService, requiredScopes ...string) fiber.Handler { return func(c *fiber.Ctx) error { token := bearerToken(c) if token == "" { token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName)) } if token == "" { return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") } verification, err := sso.VerifyAccessToken(token) if err != nil { utils.Log.WithError(err).Warn("auth: token verification failed") return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") } if verification.UserID == 0 { return fiber.NewError(fiber.StatusForbidden, "Service authentication is not permitted for this endpoint") } if err := ensureNotRevoked(c, token, verification); err != nil { return err } user, err := userService.GetBySSOUserID(c, verification.UserID) if err != nil || user == nil { utils.Log.WithError(err).Warn("auth: failed to resolve user from repository") return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") } if len(requiredScopes) > 0 { if verification.Claims == nil || !hasAllScopes(verification.Claims.Scopes(), requiredScopes) { return fiber.NewError(fiber.StatusForbidden, "Insufficient scope") } } var roles []sso.Role permissions := make(map[string]struct{}) if verification.UserID != 0 { if profile, err := sso.FetchProfile(c.Context(), token, verification); err != nil { utils.Log.WithError(err).Warn("auth: failed to fetch sso profile") } else if profile != nil { roles = profile.Roles for _, perm := range profile.PermissionNames() { if perm != "" { permissions[perm] = struct{}{} } } } } ctx := &AuthContext{ Token: token, Verification: verification, User: user, Roles: roles, Permissions: permissions, } c.Locals(authContextLocalsKey, ctx) c.Locals(authUserLocalsKey, user) return c.Next() } } // AuthenticatedUser returns the authenticated user populated by Auth. func AuthenticatedUser(c *fiber.Ctx) (*entity.User, bool) { value := c.Locals(authUserLocalsKey) if user, ok := value.(*entity.User); ok && user != nil { return user, true } return nil, false } func ActorIDFromContext(c *fiber.Ctx) (uint, error) { user, ok := AuthenticatedUser(c) if !ok || user == nil || user.Id == 0 { return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") } return user.Id, nil } // AuthDetails returns the full authentication context (token, claims, user). func AuthDetails(c *fiber.Ctx) (*AuthContext, bool) { value := c.Locals(authContextLocalsKey) if ctx, ok := value.(*AuthContext); ok && ctx != nil { return ctx, true } return nil, false } // ensureNotRevoked ensures the token is not revoked or superseded by a forced logout. func ensureNotRevoked(c *fiber.Ctx, token string, verification *sso.VerificationResult) error { revoker := session.GetRevocationStore() if revoker == nil { return nil } if fingerprint := session.TokenFingerprint(token); fingerprint != "" { revoked, err := revoker.IsRevoked(c.Context(), fingerprint) if err != nil { utils.Log.WithError(err).Warn("auth: token revocation check failed") return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") } if revoked { return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") } } if verification.UserID == 0 { return nil } logoutAt, err := revoker.UserLogoutTime(c.Context(), verification.UserID) if err != nil { utils.Log.WithError(err).Warn("auth: failed to load user logout marker") return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") } if logoutAt.IsZero() { return nil } claims := verification.Claims if claims == nil || claims.IssuedAt == nil { return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") } issuedAt := claims.IssuedAt.Time // Treat tokens issued at or before the forced logout timestamp as invalid. if !issuedAt.After(logoutAt) { return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") } return nil } // bearerToken extracts a Bearer token from the Authorization header using // case-insensitive scheme matching and tolerant whitespace handling. func bearerToken(c *fiber.Ctx) string { parts := strings.Fields(c.Get("Authorization")) if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { return strings.TrimSpace(parts[1]) } return "" } func hasAllScopes(have, required []string) bool { if len(required) == 0 { return true } set := make(map[string]struct{}, len(have)) for _, s := range have { s = strings.ToLower(strings.TrimSpace(s)) if s != "" { set[s] = struct{}{} } } for _, r := range required { r = strings.ToLower(strings.TrimSpace(r)) if r == "" { continue } if _, ok := set[r]; !ok { return false } } return true } // RequirePermissions ensures the authenticated user possesses all specified permissions. func RequirePermissions(perms ...string) fiber.Handler { required := canonicalPermissions(perms) return func(c *fiber.Ctx) error { if len(required) == 0 { return c.Next() } ctx, ok := AuthDetails(c) if !ok || ctx == nil { return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") } userPerms := ctx.permissionSet() if len(userPerms) == 0 { return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") } for _, perm := range required { if _, has := userPerms[perm]; !has { return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") } } return c.Next() } } // HasPermission reports whether the current request context includes the given permission. func HasPermission(c *fiber.Ctx, perm string) bool { ctx, ok := AuthDetails(c) if !ok || ctx == nil { return false } perm = canonicalPermission(perm) if perm == "" { return false } _, has := ctx.permissionSet()[perm] return has } func (a *AuthContext) permissionSet() map[string]struct{} { if a == nil || a.Permissions == nil { return nil } return a.Permissions } func canonicalPermissions(perms []string) []string { out := make([]string, 0, len(perms)) seen := make(map[string]struct{}, len(perms)) for _, perm := range perms { if canonical := canonicalPermission(perm); canonical != "" { if _, ok := seen[canonical]; ok { continue } seen[canonical] = struct{}{} out = append(out, canonical) } } return out } func canonicalPermission(perm string) string { return strings.ToLower(strings.TrimSpace(perm)) }