diff --git a/.github/workflows/search-deploy.yml b/.github/workflows/search-deploy.yml index 81e56b23..0214c5bb 100644 --- a/.github/workflows/search-deploy.yml +++ b/.github/workflows/search-deploy.yml @@ -50,3 +50,6 @@ jobs: --build-arg "COLLECTIONS_COLLECTION=${{ secrets.STAGING_SEARCH_SERVICE_COLLECTIONS_COLLECTION }}" --build-arg "CONTENT_TAGS_COLLECTION=${{ secrets.STAGING_SEARCH_SERVICE_CONTENT_TAGS_COLLECTION }}" --build-arg "CONTENT_COLLECTIONS_COLLECTION=${{ secrets.STAGING_SEARCH_SERVICE_CONTENT_COLLECTIONS_COLLECTION }}" + --build-arg "JWT_SECRET_KEY=${{ secrets.STAGING_SEARCH_SERVICE_JWT_SECRET_KEY }}" + --build-arg "JWT_ISSUER=${{ secrets.STAGING_SEARCH_SERVICE_JWT_ISSUER }}" + --build-arg "JWT_AUTHORIZED_APPS=${{ secrets.STAGING_SEARCH_SERVICE_JWT_AUTHORIZED_APPS }}" diff --git a/services/main/Configurations/AppConstants.cs b/services/main/Configurations/AppConstants.cs index a1c765dc..ad05359e 100644 --- a/services/main/Configurations/AppConstants.cs +++ b/services/main/Configurations/AppConstants.cs @@ -41,4 +41,6 @@ public class AppConstants public string EmailFrom { get; init; } = "Mfoni Notifications "; public string RegisterSwaggerDocs { get; set; } = null!; public string SentryDSN { get; set; } = null!; + public string SearchServiceURL { get; set; } = null!; + public string SearchServiceAuthToken { get; set; } = null!; } \ No newline at end of file diff --git a/services/main/Controllers/Content.cs b/services/main/Controllers/Content.cs index 99282f3f..44ef5669 100644 --- a/services/main/Controllers/Content.cs +++ b/services/main/Controllers/Content.cs @@ -1,5 +1,6 @@ using System.Net; using System.Security.Claims; +using main.Configuratons; using main.Domains; using main.DTOs; using main.Lib; @@ -7,6 +8,7 @@ using main.Transformations; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using Microsoft.OpenApi.Any; namespace main.Controllers; @@ -24,6 +26,8 @@ public class ContentController : ControllerBase private readonly ContentTransformer _contentTransformer; private readonly ContentLikeTransformer _contentLikeTransformer; private readonly SearchTagService _searchTagsService; + private readonly Searchcontent.SearchContentService.SearchContentServiceClient _searchServiceRpcClient; + private readonly AppConstants _appConstants; public ContentController( ILogger logger, @@ -34,7 +38,9 @@ public ContentController( ContentTransformer contentTransformer, ContentLikeTransformer contentLikeTransformer, SearchTagService searchTagsService, - CollectionContentService collectionContentService + CollectionContentService collectionContentService, + Searchcontent.SearchContentService.SearchContentServiceClient searchServiceRpcClient, + IOptions appConstants ) { _logger = logger; @@ -46,6 +52,8 @@ CollectionContentService collectionContentService _contentLikeTransformer = contentLikeTransformer; _searchTagsService = searchTagsService; _collectionContentService = collectionContentService; + _searchServiceRpcClient = searchServiceRpcClient; + _appConstants = appConstants.Value; } /// @@ -419,17 +427,45 @@ public async Task TextualSearch( _logger.LogInformation("Getting contents by textual search"); var queryFilter = HttpLib.GenerateFilterQuery(page, pageSize, sort, sortBy, populate); - var contents = await _searchContentService.TextualSearch(queryFilter, search, new GetContentsInput + Searchcontent.SearchResponse? searchResponse = null; + + try { - License = license, - Orientation = orientation - }); + var searchResults = await _searchServiceRpcClient.SearchAsync( + new Searchcontent.SearchRequest + { + Keyword = search, + // TODO: add these to the search filters + // License = license, + // Orientation = orientation + + Take = queryFilter.Limit, + Skip = queryFilter.Skip, + }, + new Grpc.Core.Metadata + { + new Grpc.Core.Metadata.Entry("Authorization", "Bearer " + _appConstants.SearchServiceAuthToken) + } + ); + + if (searchResults is null) + { + throw new Exception(); + } - var count = await _searchContentService.TextualSearchCount(search, new GetContentsInput + searchResponse = searchResults; + } + catch (System.Exception) { - License = license, - Orientation = orientation - }); + // We're hoping search service will notify us. + throw new HttpRequestException("Failed to search content"); + } + + var contentIds = searchResponse is not null ? searchResponse.Contents.Select(c => c).ToList() : new List(); + + var contents = await _searchContentService.TextualSearch(contentIds); + + var count = await _searchContentService.TextualSearchCount(contentIds); var outContents = new List(); diff --git a/services/main/Domains/Content/SearchContentService.cs b/services/main/Domains/Content/SearchContentService.cs index 124fb6d0..d1d6137e 100644 --- a/services/main/Domains/Content/SearchContentService.cs +++ b/services/main/Domains/Content/SearchContentService.cs @@ -166,75 +166,25 @@ public async Task AskRekognitionForMatch(byte[] imageBytes) } public async Task> TextualSearch( - FilterQuery queryFilter, - string query, - GetContentsInput input + List contentIds ) { - // TODO: implement search with ELASTICSEARCH - - FilterDefinitionBuilder builder = Builders.Filter; - var filter = builder.Empty; - - if (input.Orientation != "ALL") - { - var orientationFilter = builder.Eq(r => r.Media.Orientation, input.Orientation); - filter &= orientationFilter; - } - - if (input.License != "ALL") - { - if (input.License == "FREE") - { - var licenseFilter = builder.Eq(r => r.Amount, 0); - filter &= licenseFilter; - } - else if (input.License == "PREMIUM") - { - var licenseFilter = builder.Gt(r => r.Amount, 0); - filter &= licenseFilter; - } - } + var contentIdsObject = contentIds.Select(id => ObjectId.Parse(id)).ToList(); + var filter = Builders.Filter.In("_id", contentIdsObject); var contents = await _contentsCollection .Find(filter) - .Skip(queryFilter.Skip) - .Limit(queryFilter.Limit) - .Sort(queryFilter.Sort) .ToListAsync(); return contents; } public async Task TextualSearchCount( - string query, - GetContentsInput input + List contentIds ) { - // TODO: implement search with ELASTICSEARCH - - FilterDefinitionBuilder builder = Builders.Filter; - var filter = builder.Empty; - - if (input.Orientation != "ALL") - { - var orientationFilter = builder.Eq(r => r.Media.Orientation, input.Orientation); - filter &= orientationFilter; - } - - if (input.License != "ALL") - { - if (input.License == "FREE") - { - var licenseFilter = builder.Eq(r => r.Amount, 0); - filter &= licenseFilter; - } - else if (input.License == "PREMIUM") - { - var licenseFilter = builder.Gt(r => r.Amount, 0); - filter &= licenseFilter; - } - } + var contentIdsObject = contentIds.Select(id => ObjectId.Parse(id)).ToList(); + var filter = Builders.Filter.In("_id", contentIdsObject); var contents = await _contentsCollection.CountDocumentsAsync(filter); diff --git a/services/main/Makefile b/services/main/Makefile index 81a281af..7feb8192 100644 --- a/services/main/Makefile +++ b/services/main/Makefile @@ -21,4 +21,8 @@ build-docker: docker build -t mfoni-api -f Dockerfile . lint: - dotnet format main.sln \ No newline at end of file + dotnet format main.sln + +get-protos: + mkdir -p Protos + cp -r ../protos/* ./Protos \ No newline at end of file diff --git a/services/main/Program.cs b/services/main/Program.cs index 572122be..85ed3364 100644 --- a/services/main/Program.cs +++ b/services/main/Program.cs @@ -37,6 +37,14 @@ builder.Configuration.GetSection("RabbitMQConnection") ); +builder.Services.AddGrpcClient(options => +{ + options.Address = new Uri(builder + .Configuration.GetSection("AppConstants:SearchServiceURL") + .Get()! + ); +}); + builder.Services.Configure(builder.Configuration.GetSection("AppConstants")); builder.Services.AddSingleton(sp => diff --git a/services/main/Protos/content_proto/search_content.proto b/services/main/Protos/content_proto/search_content.proto new file mode 100644 index 00000000..2a2c6471 --- /dev/null +++ b/services/main/Protos/content_proto/search_content.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +option go_package="github.com/Bendomey/project-mfoni/services/search/internal/protos/content_proto"; + +package searchcontent; + +service SearchContentService { + rpc Search (SearchRequest) returns (SearchResponse) {} +} + +message SearchRequest { + string keyword = 1; + optional int64 take = 2; + optional int64 skip = 3; +} + +message SearchResponse { + repeated string contents = 1; +} diff --git a/services/main/appsettings.json b/services/main/appsettings.json index bf4a7caa..7d09a0cf 100644 --- a/services/main/appsettings.json +++ b/services/main/appsettings.json @@ -16,7 +16,9 @@ "SmsAppSecret": "", "ResendApiKey": "", "RegisterSwaggerDocs": "true", - "SentryDSN": "" + "SentryDSN": "", + "SearchServiceURL": "http://0.0.0.0:5000", + "SearchServiceAuthToken": "your-token-here" }, "Logging": { "LogLevel": { diff --git a/services/main/main.csproj b/services/main/main.csproj index b363c629..98f48d9f 100644 --- a/services/main/main.csproj +++ b/services/main/main.csproj @@ -13,6 +13,13 @@ + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + @@ -28,6 +35,8 @@ PreserveNewest + + \ No newline at end of file diff --git a/services/search/.envrc.example b/services/search/.envrc.example index a1715054..8a7c8b3a 100644 --- a/services/search/.envrc.example +++ b/services/search/.envrc.example @@ -32,3 +32,8 @@ export TAG_COLLECTION= export COLLECTIONS_COLLECTION= export CONTENT_TAGS_COLLECTION= export CONTENT_COLLECTIONS_COLLECTION= + +# JWT +JWT_SECRET_KEY= +JWT_ISSUER= +JWT_AUTHORIZED_APPS= \ No newline at end of file diff --git a/services/search/.gitignore b/services/search/.gitignore index 5f6c2c0a..5362ca3b 100644 --- a/services/search/.gitignore +++ b/services/search/.gitignore @@ -19,6 +19,8 @@ dump.rdb .env .env.local .envrc +.envrc.staging +.envrc.production ngrok diff --git a/services/search/Dockerfile b/services/search/Dockerfile index b30a4db2..456e2e6e 100644 --- a/services/search/Dockerfile +++ b/services/search/Dockerfile @@ -77,6 +77,15 @@ ENV CONTENT_TAGS_COLLECTION=$CONTENT_TAGS_COLLECTION ARG CONTENT_COLLECTIONS_COLLECTION ENV CONTENT_COLLECTIONS_COLLECTION=$CONTENT_COLLECTIONS_COLLECTION +ARG JWT_SECRET_KEY +ENV JWT_SECRET_KEY=$JWT_SECRET_KEY + +ARG JWT_ISSUER +ENV JWT_ISSUER=$JWT_ISSUER + +ARG JWT_AUTHORIZED_APPS +ENV JWT_AUTHORIZED_APPS=$JWT_AUTHORIZED_APPS + RUN mkdir /app/ WORKDIR /app/ diff --git a/services/search/config/development.yaml b/services/search/config/development.yaml index da2f3147..af9da020 100644 --- a/services/search/config/development.yaml +++ b/services/search/config/development.yaml @@ -11,6 +11,11 @@ openSearch: number_of_shards: ${NUMBER_OF_SHARDS} number_of_replicas: ${NUMBER_OF_REPLICAS} +jwt: + secret_key: ${JWT_SECRET_KEY} + issuer: ${JWT_ISSUER} + authorized_apps: ${JWT_AUTHORIZED_APPS} + sentry: dsn: ${SENTRY_DSN} environment: ${SENTRY_ENVIRONMENT} diff --git a/services/search/config/production.yaml b/services/search/config/production.yaml index da2f3147..af9da020 100644 --- a/services/search/config/production.yaml +++ b/services/search/config/production.yaml @@ -11,6 +11,11 @@ openSearch: number_of_shards: ${NUMBER_OF_SHARDS} number_of_replicas: ${NUMBER_OF_REPLICAS} +jwt: + secret_key: ${JWT_SECRET_KEY} + issuer: ${JWT_ISSUER} + authorized_apps: ${JWT_AUTHORIZED_APPS} + sentry: dsn: ${SENTRY_DSN} environment: ${SENTRY_ENVIRONMENT} diff --git a/services/search/config/staging.yaml b/services/search/config/staging.yaml index da2f3147..af9da020 100644 --- a/services/search/config/staging.yaml +++ b/services/search/config/staging.yaml @@ -11,6 +11,11 @@ openSearch: number_of_shards: ${NUMBER_OF_SHARDS} number_of_replicas: ${NUMBER_OF_REPLICAS} +jwt: + secret_key: ${JWT_SECRET_KEY} + issuer: ${JWT_ISSUER} + authorized_apps: ${JWT_AUTHORIZED_APPS} + sentry: dsn: ${SENTRY_DSN} environment: ${SENTRY_ENVIRONMENT} diff --git a/services/search/go.mod b/services/search/go.mod index 7f95f9bd..1b53ea2a 100644 --- a/services/search/go.mod +++ b/services/search/go.mod @@ -19,6 +19,7 @@ require ( require ( github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/klauspost/compress v1.17.2 // indirect diff --git a/services/search/go.sum b/services/search/go.sum index 13f1f4ec..53390179 100644 --- a/services/search/go.sum +++ b/services/search/go.sum @@ -31,6 +31,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= diff --git a/services/search/internal/handlers/content/server.go b/services/search/internal/handlers/content/server.go index 70a6799c..3373aa95 100644 --- a/services/search/internal/handlers/content/server.go +++ b/services/search/internal/handlers/content/server.go @@ -2,6 +2,7 @@ package contenthandler import ( "context" + "errors" "github.com/Bendomey/project-mfoni/services/search/internal/protos/content_proto" "github.com/Bendomey/project-mfoni/services/search/internal/services" @@ -17,8 +18,18 @@ type Handler struct { content_proto.UnimplementedSearchContentServiceServer } -func (s *Handler) Search(ctx context.Context, in *content_proto.SearchRequest) (*content_proto.SearchResponse, error) { - contents, contentsErr := s.Services.ContentService.Search(ctx, cleanUpSearchInput(in)) +func (s *Handler) Search( + ctx context.Context, + input *content_proto.SearchRequest, +) (*content_proto.SearchResponse, error) { + // verify token + isTokenValid := lib.VerifyAuthToken(ctx, s.AppContext.Config) + + if !isTokenValid { + return nil, errors.New("Unauthorized") + } + + contents, contentsErr := s.Services.ContentService.Search(ctx, cleanUpSearchInput(input)) if contentsErr != nil { logrus.Error("Error searching for content: ", contentsErr) diff --git a/services/search/pkg/lib/decode_token.go b/services/search/pkg/lib/decode_token.go new file mode 100644 index 00000000..ca73c806 --- /dev/null +++ b/services/search/pkg/lib/decode_token.go @@ -0,0 +1,91 @@ +package lib + +import ( + "context" + "errors" + "strings" + + "github.com/golang-jwt/jwt/v5" + "github.com/spf13/viper" + "google.golang.org/grpc/metadata" +) + +func VerifyAuthToken(requestCtx context.Context, config *viper.Viper) bool { + // Extract headers. + tokenWithBearer, extractAuthorizationHeader := getAuthorizationHeader(requestCtx) + if extractAuthorizationHeader != nil { + return false + } + + tokenString, extractTokenWithError := extractToken(*tokenWithBearer) + if extractTokenWithError != nil { + return false + } + + // Parse and verify the signature. + token, parseErr := jwt.Parse(*tokenString, func(_ *jwt.Token) (interface{}, error) { + return []byte(config.GetString("jwt.secret_key")), nil + }, jwt.WithIssuer(config.GetString("jwt.issuer")), jwt.WithValidMethods([]string{"HS256"})) + + if parseErr != nil { + return false + } + + if token == nil { + return false + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return false + } + + // Check if the token is valid. + if !token.Valid { + return false + } + + // Check if the app is authorized. + authorizedApps := strings.Split(config.GetString("jwt.authorized_apps"), ",") + + sub, subOk := claims["sub"].(string) + if !subOk { + return false + } + + return contains(authorizedApps, sub) +} + +func getAuthorizationHeader(ctx context.Context) (*string, error) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, errors.New("no metadata found") + } + + values, authOk := md["authorization"] + + if !authOk { + return nil, errors.New("authorization header not found") + } + + return &values[0], nil +} + +func extractToken(token string) (*string, error) { + if len(token) > 7 && token[:7] == "Bearer " { + tokenWithoutBearer := token[7:] + + return &tokenWithoutBearer, nil + } + + return nil, errors.New("invalid token") +} + +func contains(arr []string, str string) bool { + for _, v := range arr { + if v == str { + return true + } + } + return false +} diff --git a/services/search/pkg/server/main.go b/services/search/pkg/server/main.go index a882549f..50cc9d41 100644 --- a/services/search/pkg/server/main.go +++ b/services/search/pkg/server/main.go @@ -43,7 +43,7 @@ func Init() { // Register the handlers handlers.Factory(appContext, services) - listen, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", config.GetInt("app.port"))) + listen, err := net.Listen("tcp", fmt.Sprintf(":%d", config.GetInt("app.port"))) if err != nil { log.Fatalf("Could not listen on port %d: %v", config.GetInt("app.port"), err) } diff --git a/services/search/scripts/build-docker.sh b/services/search/scripts/build-docker.sh index 02ccd3f9..9c44f04d 100755 --- a/services/search/scripts/build-docker.sh +++ b/services/search/scripts/build-docker.sh @@ -23,6 +23,9 @@ docker buildx build\ --build-arg COLLECTIONS_COLLECTION=$COLLECTIONS_COLLECTION\ --build-arg CONTENT_TAGS_COLLECTION=$CONTENT_TAGS_COLLECTION\ --build-arg CONTENT_COLLECTIONS_COLLECTION=$CONTENT_COLLECTIONS_COLLECTION\ + --build-arg JWT_SECRET_KEY=$JWT_SECRET_KEY\ + --build-arg JWT_ISSUER=$JWT_ISSUER\ + --build-arg JWT_AUTHORIZED_APPS=$JWT_AUTHORIZED_APPS\ --tag mfoni-search-service . printf "Done building!"